quill-resizable-table 1.1.0
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/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/ResizableTable.d.ts +109 -0
- package/dist/index.d.ts +2 -0
- package/dist/quill-resizable-table.cjs.js +707 -0
- package/dist/quill-resizable-table.cjs.js.map +1 -0
- package/dist/quill-resizable-table.css +173 -0
- package/dist/quill-resizable-table.esm.js +705 -0
- package/dist/quill-resizable-table.esm.js.map +1 -0
- package/dist/quill-resizable-table.umd.js +713 -0
- package/dist/quill-resizable-table.umd.js.map +1 -0
- package/package.json +55 -0
- package/src/ResizableTable.ts +841 -0
- package/src/index.ts +2 -0
- package/src/quill-resizable-table.css +173 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quill-resizable-table
|
|
3
|
+
* Drag-to-resize columns, rows, and entire tables inside a Quill editor.
|
|
4
|
+
* Right-click context menu & floating edge buttons for adding/removing rows & columns.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ResizableTableOptions {
|
|
8
|
+
/** Pixel width of the invisible grab zone on each border (default 5) */
|
|
9
|
+
handleSize?: number;
|
|
10
|
+
/** Minimum column width in px (default 30) */
|
|
11
|
+
minColumnWidth?: number;
|
|
12
|
+
/** Minimum row height in px (default 20) */
|
|
13
|
+
minRowHeight?: number;
|
|
14
|
+
/** Minimum table width in px (default 50) */
|
|
15
|
+
minTableWidth?: number;
|
|
16
|
+
/** Minimum table height in px (default 30) */
|
|
17
|
+
minTableHeight?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULTS: Required<ResizableTableOptions> = {
|
|
21
|
+
handleSize: 5,
|
|
22
|
+
minColumnWidth: 30,
|
|
23
|
+
minRowHeight: 20,
|
|
24
|
+
minTableWidth: 50,
|
|
25
|
+
minTableHeight: 30,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Edge = 'col' | 'row' | 'corner';
|
|
29
|
+
|
|
30
|
+
interface DragState {
|
|
31
|
+
edge: Edge;
|
|
32
|
+
/** The table element being resized */
|
|
33
|
+
table: HTMLTableElement;
|
|
34
|
+
/** Starting mouse X */
|
|
35
|
+
startX: number;
|
|
36
|
+
/** Starting mouse Y */
|
|
37
|
+
startY: number;
|
|
38
|
+
/** Column index (for col / corner) */
|
|
39
|
+
colIndex: number;
|
|
40
|
+
/** Row index (for row / corner) */
|
|
41
|
+
rowIndex: number;
|
|
42
|
+
/** Snapshot of column widths at drag start (px) */
|
|
43
|
+
colWidths: number[];
|
|
44
|
+
/** Snapshot of row heights at drag start (px) */
|
|
45
|
+
rowHeights: number[];
|
|
46
|
+
/** Snapshot of table width at drag start */
|
|
47
|
+
tableWidth: number;
|
|
48
|
+
/** Snapshot of table height at drag start */
|
|
49
|
+
tableHeight: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ResizableTable {
|
|
53
|
+
private quill: any;
|
|
54
|
+
private options: Required<ResizableTableOptions>;
|
|
55
|
+
private drag: DragState | null = null;
|
|
56
|
+
private overlay: HTMLDivElement | null = null;
|
|
57
|
+
private doc: Document;
|
|
58
|
+
|
|
59
|
+
// Context menu
|
|
60
|
+
private contextMenu: HTMLDivElement | null = null;
|
|
61
|
+
private contextCell: HTMLTableCellElement | null = null;
|
|
62
|
+
|
|
63
|
+
// Edge buttons
|
|
64
|
+
private addColBtn: HTMLDivElement | null = null;
|
|
65
|
+
private addRowBtn: HTMLDivElement | null = null;
|
|
66
|
+
private deleteTableBtn: HTMLDivElement | null = null;
|
|
67
|
+
private hoveredTable: HTMLTableElement | null = null;
|
|
68
|
+
private hideEdgeBtnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
|
|
70
|
+
// Bound handlers so we can remove them later
|
|
71
|
+
private onMouseMoveBound: (e: MouseEvent) => void;
|
|
72
|
+
private onMouseUpBound: (e: MouseEvent) => void;
|
|
73
|
+
private onEditorMouseMoveBound: (e: MouseEvent) => void;
|
|
74
|
+
private onEditorMouseDownBound: (e: MouseEvent) => void;
|
|
75
|
+
private onContextMenuBound: (e: MouseEvent) => void;
|
|
76
|
+
private onDismissMenuBound: (e: MouseEvent) => void;
|
|
77
|
+
private onDismissMenuKeyBound: (e: KeyboardEvent) => void;
|
|
78
|
+
private onEditorMouseOverBound: (e: MouseEvent) => void;
|
|
79
|
+
private onEditorMouseOutBound: (e: MouseEvent) => void;
|
|
80
|
+
private onScrollBound: () => void;
|
|
81
|
+
|
|
82
|
+
constructor(quill: any, options: ResizableTableOptions | boolean = {}) {
|
|
83
|
+
this.quill = quill;
|
|
84
|
+
this.options = { ...DEFAULTS, ...(typeof options === 'object' ? options : {}) };
|
|
85
|
+
this.doc = (quill.root as HTMLElement).ownerDocument;
|
|
86
|
+
|
|
87
|
+
this.onMouseMoveBound = this.onDocumentMouseMove.bind(this);
|
|
88
|
+
this.onMouseUpBound = this.onDocumentMouseUp.bind(this);
|
|
89
|
+
this.onEditorMouseMoveBound = this.onEditorMouseMove.bind(this);
|
|
90
|
+
this.onEditorMouseDownBound = this.onEditorMouseDown.bind(this);
|
|
91
|
+
this.onContextMenuBound = this.onContextMenu.bind(this);
|
|
92
|
+
this.onDismissMenuBound = this.dismissContextMenu.bind(this);
|
|
93
|
+
this.onDismissMenuKeyBound = this.onKeyDown.bind(this);
|
|
94
|
+
this.onEditorMouseOverBound = this.onEditorMouseOver.bind(this);
|
|
95
|
+
this.onEditorMouseOutBound = this.onEditorMouseOut.bind(this);
|
|
96
|
+
this.onScrollBound = this.onScroll.bind(this);
|
|
97
|
+
|
|
98
|
+
this.attach();
|
|
99
|
+
this.registerToolbarHandler();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Table icon SVG for the Quill toolbar */
|
|
103
|
+
static TABLE_ICON = '<svg viewBox="0 0 18 18"><rect class="ql-stroke" height="12" width="12" x="3" y="3" fill="none"/><line class="ql-stroke" x1="3" y1="7" x2="15" y2="7"/><line class="ql-stroke" x1="3" y1="11" x2="15" y2="11"/><line class="ql-stroke" x1="7" y1="3" x2="7" y2="15"/><line class="ql-stroke" x1="11" y1="3" x2="11" y2="15"/></svg>';
|
|
104
|
+
|
|
105
|
+
/** Hook into Quill's toolbar to handle the "table" button */
|
|
106
|
+
private registerToolbarHandler(): void {
|
|
107
|
+
const toolbar = this.quill.getModule?.('toolbar');
|
|
108
|
+
if (toolbar) {
|
|
109
|
+
toolbar.addHandler('table', () => this.insertNewTable());
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Wire up listeners on the editor root */
|
|
114
|
+
private attach(): void {
|
|
115
|
+
const root = this.quill.root as HTMLElement;
|
|
116
|
+
root.addEventListener('mousemove', this.onEditorMouseMoveBound);
|
|
117
|
+
root.addEventListener('mousedown', this.onEditorMouseDownBound);
|
|
118
|
+
root.addEventListener('contextmenu', this.onContextMenuBound);
|
|
119
|
+
root.addEventListener('mouseover', this.onEditorMouseOverBound);
|
|
120
|
+
root.addEventListener('mouseout', this.onEditorMouseOutBound);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Remove all listeners (call if you ever destroy the module) */
|
|
124
|
+
public destroy(): void {
|
|
125
|
+
const root = this.quill.root as HTMLElement;
|
|
126
|
+
root.removeEventListener('mousemove', this.onEditorMouseMoveBound);
|
|
127
|
+
root.removeEventListener('mousedown', this.onEditorMouseDownBound);
|
|
128
|
+
root.removeEventListener('contextmenu', this.onContextMenuBound);
|
|
129
|
+
root.removeEventListener('mouseover', this.onEditorMouseOverBound);
|
|
130
|
+
root.removeEventListener('mouseout', this.onEditorMouseOutBound);
|
|
131
|
+
this.doc.removeEventListener('mousemove', this.onMouseMoveBound);
|
|
132
|
+
this.doc.removeEventListener('mouseup', this.onMouseUpBound);
|
|
133
|
+
this.doc.removeEventListener('scroll', this.onScrollBound, true);
|
|
134
|
+
this.removeOverlay();
|
|
135
|
+
this.dismissContextMenu();
|
|
136
|
+
this.removeEdgeButtons();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Cursor & hit-testing ────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Detect which resize edge (if any) the mouse is near.
|
|
143
|
+
* Returns null when the cursor isn't on a resize boundary.
|
|
144
|
+
*/
|
|
145
|
+
private detectEdge(
|
|
146
|
+
e: MouseEvent,
|
|
147
|
+
): { edge: Edge; table: HTMLTableElement; colIndex: number; rowIndex: number } | null {
|
|
148
|
+
const target = e.target as HTMLElement;
|
|
149
|
+
const td = target.closest('td, th') as HTMLTableCellElement | null;
|
|
150
|
+
if (!td) return null;
|
|
151
|
+
|
|
152
|
+
const table = td.closest('table') as HTMLTableElement | null;
|
|
153
|
+
if (!table) return null;
|
|
154
|
+
|
|
155
|
+
const hs = this.options.handleSize;
|
|
156
|
+
const rect = td.getBoundingClientRect();
|
|
157
|
+
|
|
158
|
+
const nearRight = e.clientX >= rect.right - hs;
|
|
159
|
+
const nearBottom = e.clientY >= rect.bottom - hs;
|
|
160
|
+
|
|
161
|
+
if (!nearRight && !nearBottom) return null;
|
|
162
|
+
|
|
163
|
+
// Determine col / row indices
|
|
164
|
+
const colIndex = this.getCellColIndex(td);
|
|
165
|
+
const rowIndex = this.getCellRowIndex(td, table);
|
|
166
|
+
|
|
167
|
+
if (nearRight && nearBottom) {
|
|
168
|
+
// Corner of a cell on the last row & last col → table resize
|
|
169
|
+
const isLastCol = colIndex + (td.colSpan || 1) - 1 === this.getColumnCount(table) - 1;
|
|
170
|
+
const isLastRow = rowIndex + (td.rowSpan || 1) - 1 === table.rows.length - 1;
|
|
171
|
+
if (isLastCol && isLastRow) {
|
|
172
|
+
return { edge: 'corner', table, colIndex, rowIndex };
|
|
173
|
+
}
|
|
174
|
+
// Otherwise prefer column resize (feels more natural)
|
|
175
|
+
return { edge: 'col', table, colIndex: colIndex + (td.colSpan || 1) - 1, rowIndex };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (nearRight) {
|
|
179
|
+
return { edge: 'col', table, colIndex: colIndex + (td.colSpan || 1) - 1, rowIndex };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// nearBottom
|
|
183
|
+
return { edge: 'row', table, colIndex, rowIndex: rowIndex + (td.rowSpan || 1) - 1 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Mouse handlers (editor) ────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** Update cursor style as the mouse moves over cells */
|
|
189
|
+
private onEditorMouseMove(e: MouseEvent): void {
|
|
190
|
+
if (this.drag) return; // already dragging
|
|
191
|
+
|
|
192
|
+
// Clear all resize cursor classes
|
|
193
|
+
const cells = (this.quill.root as HTMLElement).querySelectorAll('td.qrt-resize-col, td.qrt-resize-row, td.qrt-resize-corner, th.qrt-resize-col, th.qrt-resize-row, th.qrt-resize-corner');
|
|
194
|
+
cells.forEach(cell => {
|
|
195
|
+
cell.classList.remove('qrt-resize-col', 'qrt-resize-row', 'qrt-resize-corner');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const hit = this.detectEdge(e);
|
|
199
|
+
if (!hit) return;
|
|
200
|
+
|
|
201
|
+
// Add the appropriate cursor class to the cell
|
|
202
|
+
const target = e.target as HTMLElement;
|
|
203
|
+
const cell = target.closest('td, th') as HTMLTableCellElement | null;
|
|
204
|
+
if (!cell) return;
|
|
205
|
+
|
|
206
|
+
switch (hit.edge) {
|
|
207
|
+
case 'col':
|
|
208
|
+
cell.classList.add('qrt-resize-col');
|
|
209
|
+
break;
|
|
210
|
+
case 'row':
|
|
211
|
+
cell.classList.add('qrt-resize-row');
|
|
212
|
+
break;
|
|
213
|
+
case 'corner':
|
|
214
|
+
cell.classList.add('qrt-resize-corner');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Start a drag operation */
|
|
220
|
+
private onEditorMouseDown(e: MouseEvent): void {
|
|
221
|
+
const hit = this.detectEdge(e);
|
|
222
|
+
if (!hit) return;
|
|
223
|
+
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
e.stopPropagation();
|
|
226
|
+
|
|
227
|
+
const { table } = hit;
|
|
228
|
+
|
|
229
|
+
// Snapshot current geometry
|
|
230
|
+
const colWidths = this.getColumnWidths(table);
|
|
231
|
+
const rowHeights = this.getRowHeights(table);
|
|
232
|
+
|
|
233
|
+
this.drag = {
|
|
234
|
+
edge: hit.edge,
|
|
235
|
+
table,
|
|
236
|
+
startX: e.clientX,
|
|
237
|
+
startY: e.clientY,
|
|
238
|
+
colIndex: hit.colIndex,
|
|
239
|
+
rowIndex: hit.rowIndex,
|
|
240
|
+
colWidths,
|
|
241
|
+
rowHeights,
|
|
242
|
+
tableWidth: table.offsetWidth,
|
|
243
|
+
tableHeight: table.offsetHeight,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Apply explicit sizes so relative sizing doesn't shift
|
|
247
|
+
this.applyColumnWidths(table, colWidths);
|
|
248
|
+
this.applyRowHeights(table, rowHeights);
|
|
249
|
+
|
|
250
|
+
this.addOverlay();
|
|
251
|
+
|
|
252
|
+
this.doc.addEventListener('mousemove', this.onMouseMoveBound);
|
|
253
|
+
this.doc.addEventListener('mouseup', this.onMouseUpBound);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Mouse handlers (document, during drag) ─────────────────────
|
|
257
|
+
|
|
258
|
+
private onDocumentMouseMove(e: MouseEvent): void {
|
|
259
|
+
if (!this.drag) return;
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
|
|
262
|
+
const dx = e.clientX - this.drag.startX;
|
|
263
|
+
const dy = e.clientY - this.drag.startY;
|
|
264
|
+
|
|
265
|
+
const { edge, table, colIndex, rowIndex, colWidths, rowHeights } = this.drag;
|
|
266
|
+
|
|
267
|
+
if (edge === 'col' || edge === 'corner') {
|
|
268
|
+
const newWidth = Math.max(this.options.minColumnWidth, colWidths[colIndex] + dx);
|
|
269
|
+
this.resizeColumnDirect(table, colIndex, newWidth);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (edge === 'row' || edge === 'corner') {
|
|
273
|
+
const newHeight = Math.max(this.options.minRowHeight, rowHeights[rowIndex] + dy);
|
|
274
|
+
const updated = [...rowHeights];
|
|
275
|
+
updated[rowIndex] = newHeight;
|
|
276
|
+
this.applyRowHeights(table, updated);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private onDocumentMouseUp(_e: MouseEvent): void {
|
|
281
|
+
this.doc.removeEventListener('mousemove', this.onMouseMoveBound);
|
|
282
|
+
this.doc.removeEventListener('mouseup', this.onMouseUpBound);
|
|
283
|
+
this.drag = null;
|
|
284
|
+
this.removeOverlay();
|
|
285
|
+
(this.quill.root as HTMLElement).style.cursor = '';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Overlay (prevents text selection during drag) ──────────────
|
|
289
|
+
|
|
290
|
+
private addOverlay(): void {
|
|
291
|
+
if (this.overlay) return;
|
|
292
|
+
this.overlay = this.doc.createElement('div');
|
|
293
|
+
Object.assign(this.overlay.style, {
|
|
294
|
+
position: 'fixed',
|
|
295
|
+
top: '0',
|
|
296
|
+
left: '0',
|
|
297
|
+
width: '100vw',
|
|
298
|
+
height: '100vh',
|
|
299
|
+
zIndex: '9999',
|
|
300
|
+
cursor: this.drag
|
|
301
|
+
? this.drag.edge === 'col'
|
|
302
|
+
? 'col-resize'
|
|
303
|
+
: this.drag.edge === 'row'
|
|
304
|
+
? 'row-resize'
|
|
305
|
+
: 'nwse-resize'
|
|
306
|
+
: 'default',
|
|
307
|
+
});
|
|
308
|
+
this.doc.body.appendChild(this.overlay);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private removeOverlay(): void {
|
|
312
|
+
if (this.overlay) {
|
|
313
|
+
this.overlay.remove();
|
|
314
|
+
this.overlay = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Geometry helpers ───────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/** Get the visual column index of a cell accounting for previous colSpans */
|
|
321
|
+
private getCellColIndex(td: HTMLTableCellElement): number {
|
|
322
|
+
let index = 0;
|
|
323
|
+
let sibling = td.previousElementSibling as HTMLTableCellElement | null;
|
|
324
|
+
while (sibling) {
|
|
325
|
+
index += sibling.colSpan || 1;
|
|
326
|
+
sibling = sibling.previousElementSibling as HTMLTableCellElement | null;
|
|
327
|
+
}
|
|
328
|
+
return index;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Get the row index of a cell */
|
|
332
|
+
private getCellRowIndex(td: HTMLTableCellElement, table: HTMLTableElement): number {
|
|
333
|
+
const row = td.parentElement as HTMLTableRowElement;
|
|
334
|
+
return Array.from(table.rows).indexOf(row);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Total number of visual columns in the first row */
|
|
338
|
+
private getColumnCount(table: HTMLTableElement): number {
|
|
339
|
+
if (table.rows.length === 0) return 0;
|
|
340
|
+
const firstRow = table.rows[0];
|
|
341
|
+
let count = 0;
|
|
342
|
+
for (let i = 0; i < firstRow.cells.length; i++) {
|
|
343
|
+
count += firstRow.cells[i].colSpan || 1;
|
|
344
|
+
}
|
|
345
|
+
return count;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Read current column widths from the first row's cells */
|
|
349
|
+
private getColumnWidths(table: HTMLTableElement): number[] {
|
|
350
|
+
if (table.rows.length === 0) return [];
|
|
351
|
+
const firstRow = table.rows[0];
|
|
352
|
+
const widths: number[] = [];
|
|
353
|
+
for (let i = 0; i < firstRow.cells.length; i++) {
|
|
354
|
+
const cell = firstRow.cells[i];
|
|
355
|
+
const span = cell.colSpan || 1;
|
|
356
|
+
const w = cell.offsetWidth / span;
|
|
357
|
+
for (let s = 0; s < span; s++) {
|
|
358
|
+
widths.push(w);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return widths;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Read current row heights */
|
|
365
|
+
private getRowHeights(table: HTMLTableElement): number[] {
|
|
366
|
+
return Array.from(table.rows).map((row) => row.offsetHeight);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Apply column widths via a <colgroup> */
|
|
370
|
+
private applyColumnWidths(table: HTMLTableElement, widths: number[]): void {
|
|
371
|
+
// Ensure table has layout=fixed for predictable sizing
|
|
372
|
+
table.style.tableLayout = 'fixed';
|
|
373
|
+
table.style.width = widths.reduce((a, b) => a + b, 0) + 'px';
|
|
374
|
+
|
|
375
|
+
const doc = table.ownerDocument;
|
|
376
|
+
let colgroup = table.querySelector('colgroup');
|
|
377
|
+
if (!colgroup) {
|
|
378
|
+
colgroup = doc.createElement('colgroup');
|
|
379
|
+
table.insertBefore(colgroup, table.firstChild);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Sync <col> elements
|
|
383
|
+
while (colgroup.children.length > widths.length) {
|
|
384
|
+
colgroup.removeChild(colgroup.lastChild!);
|
|
385
|
+
}
|
|
386
|
+
while (colgroup.children.length < widths.length) {
|
|
387
|
+
colgroup.appendChild(doc.createElement('col'));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (let i = 0; i < widths.length; i++) {
|
|
391
|
+
(colgroup.children[i] as HTMLElement).style.width = widths[i] + 'px';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Resize a single column independently (only that column changes width) */
|
|
396
|
+
private resizeColumnDirect(table: HTMLTableElement, colIndex: number, newWidth: number): void {
|
|
397
|
+
const doc = table.ownerDocument;
|
|
398
|
+
table.style.tableLayout = 'fixed';
|
|
399
|
+
|
|
400
|
+
// Get or create colgroup
|
|
401
|
+
let colgroup = table.querySelector('colgroup');
|
|
402
|
+
if (!colgroup) {
|
|
403
|
+
colgroup = doc.createElement('colgroup');
|
|
404
|
+
table.insertBefore(colgroup, table.firstChild);
|
|
405
|
+
|
|
406
|
+
// Initialize all cols with current widths
|
|
407
|
+
const colWidths = this.getColumnWidths(table);
|
|
408
|
+
for (const width of colWidths) {
|
|
409
|
+
const col = doc.createElement('col');
|
|
410
|
+
col.style.width = width + 'px';
|
|
411
|
+
colgroup.appendChild(col);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Ensure we have enough col elements
|
|
416
|
+
while (colgroup.children.length < colIndex + 1) {
|
|
417
|
+
colgroup.appendChild(doc.createElement('col'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Resize just this column
|
|
421
|
+
(colgroup.children[colIndex] as HTMLElement).style.width = newWidth + 'px';
|
|
422
|
+
|
|
423
|
+
// Update table width to be sum of all columns
|
|
424
|
+
const allCols = Array.from(colgroup.children) as HTMLElement[];
|
|
425
|
+
const totalWidth = allCols.reduce((sum, col) => {
|
|
426
|
+
const w = col.style.width;
|
|
427
|
+
return sum + (w ? parseFloat(w) : 0);
|
|
428
|
+
}, 0);
|
|
429
|
+
table.style.width = totalWidth + 'px';
|
|
430
|
+
|
|
431
|
+
this.syncQuill();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Apply row heights directly on <tr> elements */
|
|
435
|
+
private applyRowHeights(table: HTMLTableElement, heights: number[]): void {
|
|
436
|
+
const rows = table.rows;
|
|
437
|
+
for (let i = 0; i < heights.length && i < rows.length; i++) {
|
|
438
|
+
rows[i].style.height = heights[i] + 'px';
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ─── Context menu ─────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
private onContextMenu(e: MouseEvent): void {
|
|
445
|
+
const target = e.target as HTMLElement;
|
|
446
|
+
const td = target.closest('td, th') as HTMLTableCellElement | null;
|
|
447
|
+
if (!td) return;
|
|
448
|
+
|
|
449
|
+
const table = td.closest('table') as HTMLTableElement | null;
|
|
450
|
+
if (!table) return;
|
|
451
|
+
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
e.stopPropagation();
|
|
454
|
+
|
|
455
|
+
this.contextCell = td;
|
|
456
|
+
this.showContextMenu(e.clientX, e.clientY, td, table);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private showContextMenu(
|
|
460
|
+
x: number,
|
|
461
|
+
y: number,
|
|
462
|
+
td: HTMLTableCellElement,
|
|
463
|
+
table: HTMLTableElement,
|
|
464
|
+
): void {
|
|
465
|
+
this.dismissContextMenu();
|
|
466
|
+
|
|
467
|
+
const menu = this.doc.createElement('div');
|
|
468
|
+
menu.className = 'qrt-context-menu';
|
|
469
|
+
Object.assign(menu.style, {
|
|
470
|
+
position: 'fixed',
|
|
471
|
+
left: x + 'px',
|
|
472
|
+
top: y + 'px',
|
|
473
|
+
zIndex: '10000',
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const colIndex = this.getCellColIndex(td);
|
|
477
|
+
const rowIndex = this.getCellRowIndex(td, table);
|
|
478
|
+
const colCount = this.getColumnCount(table);
|
|
479
|
+
const rowCount = table.rows.length;
|
|
480
|
+
|
|
481
|
+
const items: { label: string; action: () => void; dividerAfter?: boolean; disabled?: boolean }[] = [
|
|
482
|
+
{ label: 'Insert Column Left', action: () => this.insertColumn(table, colIndex, 'before') },
|
|
483
|
+
{ label: 'Insert Column Right', action: () => this.insertColumn(table, colIndex, 'after') },
|
|
484
|
+
{ label: 'Delete Column', action: () => this.deleteColumn(table, colIndex), dividerAfter: true, disabled: colCount <= 1 },
|
|
485
|
+
{ label: 'Insert Row Above', action: () => this.insertRow(table, rowIndex, 'before') },
|
|
486
|
+
{ label: 'Insert Row Below', action: () => this.insertRow(table, rowIndex, 'after') },
|
|
487
|
+
{ label: 'Delete Row', action: () => this.deleteRow(table, rowIndex), dividerAfter: true, disabled: rowCount <= 1 },
|
|
488
|
+
{ label: 'Delete Table', action: () => this.deleteTable(table) },
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
for (const item of items) {
|
|
492
|
+
const el = this.doc.createElement('div');
|
|
493
|
+
el.className = 'qrt-context-menu-item' + (item.disabled ? ' qrt-disabled' : '');
|
|
494
|
+
el.textContent = item.label;
|
|
495
|
+
if (!item.disabled) {
|
|
496
|
+
el.addEventListener('mousedown', (ev) => {
|
|
497
|
+
ev.preventDefault();
|
|
498
|
+
ev.stopPropagation();
|
|
499
|
+
item.action();
|
|
500
|
+
this.dismissContextMenu();
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
menu.appendChild(el);
|
|
504
|
+
|
|
505
|
+
if (item.dividerAfter) {
|
|
506
|
+
const divider = this.doc.createElement('div');
|
|
507
|
+
divider.className = 'qrt-context-menu-divider';
|
|
508
|
+
menu.appendChild(divider);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
this.doc.body.appendChild(menu);
|
|
513
|
+
this.contextMenu = menu;
|
|
514
|
+
|
|
515
|
+
// Dismiss on click outside or Escape
|
|
516
|
+
setTimeout(() => {
|
|
517
|
+
this.doc.addEventListener('mousedown', this.onDismissMenuBound);
|
|
518
|
+
this.doc.addEventListener('keydown', this.onDismissMenuKeyBound);
|
|
519
|
+
}, 0);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private dismissContextMenu(): void {
|
|
523
|
+
if (this.contextMenu) {
|
|
524
|
+
this.contextMenu.remove();
|
|
525
|
+
this.contextMenu = null;
|
|
526
|
+
this.contextCell = null;
|
|
527
|
+
this.doc.removeEventListener('mousedown', this.onDismissMenuBound);
|
|
528
|
+
this.doc.removeEventListener('keydown', this.onDismissMenuKeyBound);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private onKeyDown(e: KeyboardEvent): void {
|
|
533
|
+
if (e.key === 'Escape') {
|
|
534
|
+
this.dismissContextMenu();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Edge buttons (+ buttons on table hover) ─────────────────
|
|
539
|
+
|
|
540
|
+
private cancelHideEdgeButtons(): void {
|
|
541
|
+
if (this.hideEdgeBtnTimer) {
|
|
542
|
+
clearTimeout(this.hideEdgeBtnTimer);
|
|
543
|
+
this.hideEdgeBtnTimer = null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private scheduleHideEdgeButtons(): void {
|
|
548
|
+
this.cancelHideEdgeButtons();
|
|
549
|
+
this.hideEdgeBtnTimer = setTimeout(() => {
|
|
550
|
+
this.removeEdgeButtons();
|
|
551
|
+
this.hoveredTable = null;
|
|
552
|
+
}, 200);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private onEditorMouseOver(e: MouseEvent): void {
|
|
556
|
+
const target = e.target as HTMLElement;
|
|
557
|
+
const table = target.closest('table') as HTMLTableElement | null;
|
|
558
|
+
|
|
559
|
+
// Mouse re-entered the same table or a button — cancel any pending hide
|
|
560
|
+
if (table && table === this.hoveredTable) {
|
|
561
|
+
this.cancelHideEdgeButtons();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (!table) return;
|
|
566
|
+
|
|
567
|
+
this.cancelHideEdgeButtons();
|
|
568
|
+
this.removeEdgeButtons();
|
|
569
|
+
this.hoveredTable = table;
|
|
570
|
+
this.showEdgeButtons(table);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private onEditorMouseOut(e: MouseEvent): void {
|
|
574
|
+
const related = e.relatedTarget as HTMLElement | null;
|
|
575
|
+
if (!this.hoveredTable) return;
|
|
576
|
+
|
|
577
|
+
// Still inside the table or on a button? keep showing
|
|
578
|
+
if (related && (
|
|
579
|
+
this.hoveredTable.contains(related) ||
|
|
580
|
+
this.addColBtn?.contains(related) ||
|
|
581
|
+
this.addRowBtn?.contains(related) ||
|
|
582
|
+
this.deleteTableBtn?.contains(related)
|
|
583
|
+
)) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Delay removal so the user can cross the gap to reach the button
|
|
588
|
+
this.scheduleHideEdgeButtons();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Update button positions on scroll to keep them anchored to the table */
|
|
592
|
+
private onScroll(): void {
|
|
593
|
+
if (!this.hoveredTable) return;
|
|
594
|
+
|
|
595
|
+
const rect = this.hoveredTable.getBoundingClientRect();
|
|
596
|
+
|
|
597
|
+
if (this.addColBtn) {
|
|
598
|
+
Object.assign(this.addColBtn.style, {
|
|
599
|
+
left: (rect.right + 4) + 'px',
|
|
600
|
+
top: (rect.top + rect.height / 2 - 12) + 'px',
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (this.addRowBtn) {
|
|
605
|
+
Object.assign(this.addRowBtn.style, {
|
|
606
|
+
left: (rect.left + rect.width / 2 - 12) + 'px',
|
|
607
|
+
top: (rect.bottom + 4) + 'px',
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (this.deleteTableBtn) {
|
|
612
|
+
Object.assign(this.deleteTableBtn.style, {
|
|
613
|
+
right: (window.innerWidth - rect.right + 4) + 'px',
|
|
614
|
+
top: (rect.top - 20) + 'px',
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private showEdgeButtons(table: HTMLTableElement): void {
|
|
620
|
+
const rect = table.getBoundingClientRect();
|
|
621
|
+
|
|
622
|
+
// Attach scroll listener to reposition buttons
|
|
623
|
+
this.doc.addEventListener('scroll', this.onScrollBound, true);
|
|
624
|
+
|
|
625
|
+
// + Column button (right edge, vertically centered)
|
|
626
|
+
this.addColBtn = this.doc.createElement('div');
|
|
627
|
+
this.addColBtn.className = 'qrt-edge-btn';
|
|
628
|
+
this.addColBtn.textContent = '+';
|
|
629
|
+
this.addColBtn.title = 'Add column';
|
|
630
|
+
Object.assign(this.addColBtn.style, {
|
|
631
|
+
position: 'fixed',
|
|
632
|
+
left: (rect.right + 4) + 'px',
|
|
633
|
+
top: (rect.top + rect.height / 2 - 12) + 'px',
|
|
634
|
+
zIndex: '10000',
|
|
635
|
+
cursor: 'pointer',
|
|
636
|
+
});
|
|
637
|
+
this.addColBtn.addEventListener('mousedown', (ev) => {
|
|
638
|
+
ev.preventDefault();
|
|
639
|
+
ev.stopPropagation();
|
|
640
|
+
this.cancelHideEdgeButtons();
|
|
641
|
+
const colCount = this.getColumnCount(table);
|
|
642
|
+
this.insertColumn(table, colCount - 1, 'after');
|
|
643
|
+
this.removeEdgeButtons();
|
|
644
|
+
this.showEdgeButtons(table);
|
|
645
|
+
});
|
|
646
|
+
this.addColBtn.addEventListener('mouseenter', () => {
|
|
647
|
+
this.cancelHideEdgeButtons();
|
|
648
|
+
});
|
|
649
|
+
this.addColBtn.addEventListener('mouseleave', (ev) => {
|
|
650
|
+
const related = ev.relatedTarget as HTMLElement | null;
|
|
651
|
+
if (related && (table.contains(related) || this.addRowBtn?.contains(related))) return;
|
|
652
|
+
this.scheduleHideEdgeButtons();
|
|
653
|
+
});
|
|
654
|
+
this.doc.body.appendChild(this.addColBtn);
|
|
655
|
+
|
|
656
|
+
// + Row button (bottom edge, horizontally centered)
|
|
657
|
+
this.addRowBtn = this.doc.createElement('div');
|
|
658
|
+
this.addRowBtn.className = 'qrt-edge-btn';
|
|
659
|
+
this.addRowBtn.textContent = '+';
|
|
660
|
+
this.addRowBtn.title = 'Add row';
|
|
661
|
+
Object.assign(this.addRowBtn.style, {
|
|
662
|
+
position: 'fixed',
|
|
663
|
+
left: (rect.left + rect.width / 2 - 12) + 'px',
|
|
664
|
+
top: (rect.bottom + 4) + 'px',
|
|
665
|
+
zIndex: '10000',
|
|
666
|
+
cursor: 'pointer',
|
|
667
|
+
});
|
|
668
|
+
this.addRowBtn.addEventListener('mousedown', (ev) => {
|
|
669
|
+
ev.preventDefault();
|
|
670
|
+
ev.stopPropagation();
|
|
671
|
+
this.cancelHideEdgeButtons();
|
|
672
|
+
this.insertRow(table, table.rows.length - 1, 'after');
|
|
673
|
+
this.removeEdgeButtons();
|
|
674
|
+
this.showEdgeButtons(table);
|
|
675
|
+
});
|
|
676
|
+
this.addRowBtn.addEventListener('mouseenter', () => {
|
|
677
|
+
this.cancelHideEdgeButtons();
|
|
678
|
+
});
|
|
679
|
+
this.addRowBtn.addEventListener('mouseleave', (ev) => {
|
|
680
|
+
const related = ev.relatedTarget as HTMLElement | null;
|
|
681
|
+
if (related && (table.contains(related) || this.addColBtn?.contains(related))) return;
|
|
682
|
+
this.scheduleHideEdgeButtons();
|
|
683
|
+
});
|
|
684
|
+
this.doc.body.appendChild(this.addRowBtn);
|
|
685
|
+
|
|
686
|
+
// Delete Table button (top-right corner)
|
|
687
|
+
this.deleteTableBtn = this.doc.createElement('div');
|
|
688
|
+
this.deleteTableBtn.className = 'qrt-delete-table-btn';
|
|
689
|
+
this.deleteTableBtn.innerHTML = '✕';
|
|
690
|
+
this.deleteTableBtn.title = 'Delete table';
|
|
691
|
+
Object.assign(this.deleteTableBtn.style, {
|
|
692
|
+
position: 'fixed',
|
|
693
|
+
right: (window.innerWidth - rect.right + 4) + 'px',
|
|
694
|
+
top: (rect.top - 20) + 'px',
|
|
695
|
+
zIndex: '10000',
|
|
696
|
+
cursor: 'pointer',
|
|
697
|
+
});
|
|
698
|
+
this.deleteTableBtn.addEventListener('mousedown', (ev) => {
|
|
699
|
+
ev.preventDefault();
|
|
700
|
+
ev.stopPropagation();
|
|
701
|
+
this.cancelHideEdgeButtons();
|
|
702
|
+
this.deleteTable(table);
|
|
703
|
+
this.removeEdgeButtons();
|
|
704
|
+
});
|
|
705
|
+
this.deleteTableBtn.addEventListener('mouseenter', () => {
|
|
706
|
+
this.cancelHideEdgeButtons();
|
|
707
|
+
});
|
|
708
|
+
this.deleteTableBtn.addEventListener('mouseleave', (ev) => {
|
|
709
|
+
const related = ev.relatedTarget as HTMLElement | null;
|
|
710
|
+
if (related && (table.contains(related) || this.addColBtn?.contains(related) || this.addRowBtn?.contains(related))) return;
|
|
711
|
+
this.scheduleHideEdgeButtons();
|
|
712
|
+
});
|
|
713
|
+
this.doc.body.appendChild(this.deleteTableBtn);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private removeEdgeButtons(): void {
|
|
717
|
+
this.cancelHideEdgeButtons();
|
|
718
|
+
if (this.addColBtn) { this.addColBtn.remove(); this.addColBtn = null; }
|
|
719
|
+
if (this.addRowBtn) { this.addRowBtn.remove(); this.addRowBtn = null; }
|
|
720
|
+
if (this.deleteTableBtn) { this.deleteTableBtn.remove(); this.deleteTableBtn = null; }
|
|
721
|
+
// Remove scroll listener
|
|
722
|
+
this.doc.removeEventListener('scroll', this.onScrollBound, true);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ─── Table creation ─────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
/** Insert a new 3×3 table at the current cursor position */
|
|
728
|
+
public insertNewTable(rows = 3, cols = 3): void {
|
|
729
|
+
const range = this.quill.getSelection?.(true);
|
|
730
|
+
if (!range) return;
|
|
731
|
+
|
|
732
|
+
// Build table HTML
|
|
733
|
+
const cellHTML = '<td><br></td>';
|
|
734
|
+
const rowHTML = `<tr>${cellHTML.repeat(cols)}</tr>`;
|
|
735
|
+
const tableHTML = `<table><tbody>${rowHTML.repeat(rows)}</tbody></table>`;
|
|
736
|
+
|
|
737
|
+
// Insert at cursor via clipboard (preserves Quill delta consistency)
|
|
738
|
+
this.quill.clipboard.dangerouslyPasteHTML(range.index, tableHTML, 'user');
|
|
739
|
+
this.syncQuill();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ─── Table mutation methods ───────────────────────────────────
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Consume pending MutationObserver records so Quill's async handler
|
|
746
|
+
* never processes our structural DOM changes (which it would corrupt).
|
|
747
|
+
* Style-only changes (resize) don't need this — only structural ones.
|
|
748
|
+
*/
|
|
749
|
+
private syncQuill(): void {
|
|
750
|
+
try {
|
|
751
|
+
const scroll = this.quill?.scroll;
|
|
752
|
+
if (scroll?.observer) {
|
|
753
|
+
// Grab and discard all pending mutation records before
|
|
754
|
+
// Quill's microtask callback can process them
|
|
755
|
+
scroll.observer.takeRecords();
|
|
756
|
+
}
|
|
757
|
+
} catch {
|
|
758
|
+
// Quill internals not accessible — ignore silently
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Insert a column before or after colIndex */
|
|
763
|
+
public insertColumn(table: HTMLTableElement, colIndex: number, position: 'before' | 'after'): void {
|
|
764
|
+
const targetIndex = position === 'after' ? colIndex + 1 : colIndex;
|
|
765
|
+
|
|
766
|
+
for (let r = 0; r < table.rows.length; r++) {
|
|
767
|
+
const row = table.rows[r];
|
|
768
|
+
const newCell = row.insertCell(Math.min(targetIndex, row.cells.length));
|
|
769
|
+
newCell.innerHTML = '<br>';
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Update colgroup if it exists
|
|
773
|
+
const colgroup = table.querySelector('colgroup');
|
|
774
|
+
if (colgroup) {
|
|
775
|
+
const col = table.ownerDocument.createElement('col');
|
|
776
|
+
col.style.width = this.options.minColumnWidth + 'px';
|
|
777
|
+
if (targetIndex < colgroup.children.length) {
|
|
778
|
+
colgroup.insertBefore(col, colgroup.children[targetIndex]);
|
|
779
|
+
} else {
|
|
780
|
+
colgroup.appendChild(col);
|
|
781
|
+
}
|
|
782
|
+
// Recalculate table width
|
|
783
|
+
table.style.width = (table.offsetWidth + this.options.minColumnWidth) + 'px';
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
this.syncQuill();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Delete column at colIndex (no-op if only 1 column remains) */
|
|
790
|
+
public deleteColumn(table: HTMLTableElement, colIndex: number): void {
|
|
791
|
+
if (this.getColumnCount(table) <= 1) return;
|
|
792
|
+
|
|
793
|
+
for (let r = 0; r < table.rows.length; r++) {
|
|
794
|
+
const row = table.rows[r];
|
|
795
|
+
if (colIndex < row.cells.length) {
|
|
796
|
+
row.deleteCell(colIndex);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Update colgroup if it exists
|
|
801
|
+
const colgroup = table.querySelector('colgroup');
|
|
802
|
+
if (colgroup && colIndex < colgroup.children.length) {
|
|
803
|
+
colgroup.removeChild(colgroup.children[colIndex]);
|
|
804
|
+
// Recalculate table width from remaining cols
|
|
805
|
+
let total = 0;
|
|
806
|
+
for (let i = 0; i < colgroup.children.length; i++) {
|
|
807
|
+
total += parseInt((colgroup.children[i] as HTMLElement).style.width, 10) || this.options.minColumnWidth;
|
|
808
|
+
}
|
|
809
|
+
table.style.width = total + 'px';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this.syncQuill();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Insert a row before or after rowIndex */
|
|
816
|
+
public insertRow(table: HTMLTableElement, rowIndex: number, position: 'before' | 'after'): void {
|
|
817
|
+
const targetIndex = position === 'after' ? rowIndex + 1 : rowIndex;
|
|
818
|
+
const colCount = this.getColumnCount(table);
|
|
819
|
+
const newRow = table.insertRow(Math.min(targetIndex, table.rows.length));
|
|
820
|
+
|
|
821
|
+
for (let c = 0; c < colCount; c++) {
|
|
822
|
+
const cell = newRow.insertCell();
|
|
823
|
+
cell.innerHTML = '<br>';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
this.syncQuill();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Delete row at rowIndex (no-op if only 1 row remains) */
|
|
830
|
+
public deleteRow(table: HTMLTableElement, rowIndex: number): void {
|
|
831
|
+
if (table.rows.length <= 1) return;
|
|
832
|
+
table.deleteRow(rowIndex);
|
|
833
|
+
this.syncQuill();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Delete the entire table */
|
|
837
|
+
public deleteTable(table: HTMLTableElement): void {
|
|
838
|
+
table.remove();
|
|
839
|
+
this.syncQuill();
|
|
840
|
+
}
|
|
841
|
+
}
|