]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts
37f1b6f01ee8ea2feb3b7fc4da47b3bbcdf17786
[bookstack] / resources / js / wysiwyg / ui / framework / helpers / table-resizer.ts
1 import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical";
2 import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker";
3 import {CustomTableNode} from "../../../nodes/custom-table";
4 import {TableRowNode} from "@lexical/table";
5 import {el} from "../../../utils/dom";
6 import {$getTableColumnWidth, $setTableColumnWidth} from "../../../utils/tables";
7
8 type MarkerDomRecord = {x: HTMLElement, y: HTMLElement};
9
10 class TableResizer {
11     protected editor: LexicalEditor;
12     protected editScrollContainer: HTMLElement;
13     protected markerDom: MarkerDomRecord|null = null;
14     protected mouseTracker: MouseDragTracker|null = null;
15     protected dragging: boolean = false;
16     protected targetCell: HTMLElement|null = null;
17     protected xMarkerAtStart : boolean = false;
18     protected yMarkerAtStart : boolean = false;
19
20     constructor(editor: LexicalEditor, editScrollContainer: HTMLElement) {
21         this.editor = editor;
22         this.editScrollContainer = editScrollContainer;
23
24         this.setupListeners();
25     }
26
27     teardown() {
28         this.editScrollContainer.removeEventListener('mousemove', this.onCellMouseMove);
29         window.removeEventListener('scroll', this.onScrollOrResize, {capture: true});
30         window.removeEventListener('resize', this.onScrollOrResize);
31         if (this.mouseTracker) {
32             this.mouseTracker.teardown();
33         }
34     }
35
36     protected setupListeners() {
37         this.onCellMouseMove = this.onCellMouseMove.bind(this);
38         this.onScrollOrResize = this.onScrollOrResize.bind(this);
39         this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove);
40         window.addEventListener('scroll', this.onScrollOrResize, {capture: true, passive: true});
41         window.addEventListener('resize', this.onScrollOrResize, {passive: true});
42     }
43
44     protected onScrollOrResize(): void {
45         this.updateCurrentMarkerTargetPosition();
46     }
47
48     protected onCellMouseMove(event: MouseEvent) {
49         const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement;
50         if (!cell || this.dragging) {
51             return;
52         }
53
54         const rect = cell.getBoundingClientRect();
55         const midX = rect.left + (rect.width / 2);
56         const midY = rect.top + (rect.height / 2);
57
58         this.targetCell = cell;
59         this.xMarkerAtStart = event.clientX <= midX;
60         this.yMarkerAtStart = event.clientY <= midY;
61
62         const xMarkerPos = this.xMarkerAtStart ? rect.left : rect.right;
63         const yMarkerPos = this.yMarkerAtStart ? rect.top : rect.bottom;
64         this.updateMarkersTo(cell, xMarkerPos, yMarkerPos);
65     }
66
67     protected updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) {
68         const markers: MarkerDomRecord = this.getMarkers();
69         const table = cell.closest('table') as HTMLElement;
70         const tableRect = table.getBoundingClientRect();
71         const editBounds = this.editScrollContainer.getBoundingClientRect();
72
73         const maxTop = Math.max(tableRect.top, editBounds.top);
74         const maxBottom = Math.min(tableRect.bottom, editBounds.bottom);
75         const maxHeight = maxBottom - maxTop;
76         markers.x.style.left = xPos + 'px';
77         markers.x.style.top = maxTop + 'px';
78         markers.x.style.height = maxHeight + 'px';
79
80         markers.y.style.top = yPos + 'px';
81         markers.y.style.left = tableRect.left + 'px';
82         markers.y.style.width = tableRect.width + 'px';
83
84         // Hide markers when out of bounds
85         markers.y.hidden = yPos < editBounds.top || yPos > editBounds.bottom;
86         markers.x.hidden = tableRect.top > editBounds.bottom || tableRect.bottom < editBounds.top;
87     }
88
89     protected updateCurrentMarkerTargetPosition(): void {
90         if (!this.targetCell) {
91             return;
92         }
93
94         const rect = this.targetCell.getBoundingClientRect();
95         const xMarkerPos = this.xMarkerAtStart ? rect.left : rect.right;
96         const yMarkerPos = this.yMarkerAtStart ? rect.top : rect.bottom;
97         this.updateMarkersTo(this.targetCell, xMarkerPos, yMarkerPos);
98     }
99
100     protected getMarkers(): MarkerDomRecord {
101         if (!this.markerDom) {
102             this.markerDom = {
103                 x: el('div', {class: 'editor-table-marker editor-table-marker-column'}),
104                 y: el('div', {class: 'editor-table-marker editor-table-marker-row'}),
105             }
106             const wrapper = el('div', {
107                 class: 'editor-table-marker-wrap',
108             }, [this.markerDom.x, this.markerDom.y]);
109             this.editScrollContainer.after(wrapper);
110             this.watchMarkerMouseDrags(wrapper);
111         }
112
113         return this.markerDom;
114     }
115
116     protected watchMarkerMouseDrags(wrapper: HTMLElement) {
117         const _this = this;
118         let markerStart: number = 0;
119         let markerProp: 'left' | 'top' = 'left';
120
121         this.mouseTracker = new MouseDragTracker(wrapper, '.editor-table-marker', {
122             down(event: MouseEvent, marker: HTMLElement) {
123                 marker.classList.add('active');
124                 _this.dragging = true;
125
126                 markerProp = marker.classList.contains('editor-table-marker-column') ? 'left' : 'top';
127                 markerStart = Number(marker.style[markerProp].replace('px', ''));
128             },
129             move(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) {
130                   marker.style[markerProp] = (markerStart + distance[markerProp === 'left' ? 'x' : 'y']) + 'px';
131             },
132             up(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) {
133                 marker.classList.remove('active');
134                 marker.style.left = '0';
135                 marker.style.top = '0';
136
137                 _this.dragging = false;
138                 const parentTable = _this.targetCell?.closest('table');
139
140                 if (markerProp === 'left' && _this.targetCell && parentTable) {
141                     let cellIndex = _this.getTargetCellColumnIndex();
142                     let change = distance.x;
143                     if (_this.xMarkerAtStart && cellIndex > 0) {
144                         cellIndex -= 1;
145                     } else if  (_this.xMarkerAtStart && cellIndex === 0) {
146                         change = -change;
147                     }
148
149                     _this.editor.update(() => {
150                         const table = $getNearestNodeFromDOMNode(parentTable);
151                         if (table instanceof CustomTableNode) {
152                             const originalWidth = $getTableColumnWidth(_this.editor, table, cellIndex);
153                             const newWidth = Math.max(originalWidth + change, 10);
154                             $setTableColumnWidth(table, cellIndex, newWidth);
155                         }
156                     });
157                 }
158
159                 if (markerProp === 'top' && _this.targetCell) {
160                     const cellElement = _this.targetCell;
161
162                     _this.editor.update(() => {
163                         const cellNode = $getNearestNodeFromDOMNode(cellElement);
164                         const rowNode = cellNode?.getParent();
165                         let rowIndex = rowNode?.getIndexWithinParent() || 0;
166
167                         let change = distance.y;
168                         if (_this.yMarkerAtStart && rowIndex > 0) {
169                             rowIndex -= 1;
170                         } else if  (_this.yMarkerAtStart && rowIndex === 0) {
171                             change = -change;
172                         }
173
174                         const targetRow = rowNode?.getParent()?.getChildren()[rowIndex];
175                         if (targetRow instanceof TableRowNode) {
176                             const height  = targetRow.getHeight() || 0;
177                             const newHeight = Math.max(height + change, 10);
178                             targetRow.setHeight(newHeight);
179                         }
180                     });
181                 }
182             }
183         });
184     }
185
186     protected getTargetCellColumnIndex(): number {
187         const cell = this.targetCell;
188         if (cell === null) {
189             return -1;
190         }
191
192         let index = 0;
193         const row = cell.parentElement;
194         for (const rowCell of row?.children || []) {
195             let size = Number(rowCell.getAttribute('colspan'));
196             if (Number.isNaN(size) || size < 1) {
197                 size = 1;
198             }
199
200             index += size;
201
202             if (rowCell === cell) {
203                 return index - 1;
204             }
205         }
206
207         return -1;
208     }
209 }
210
211
212 export function registerTableResizer(editor: LexicalEditor, editScrollContainer: HTMLElement): (() => void) {
213     const resizer = new TableResizer(editor, editScrollContainer);
214
215     return () => {
216         resizer.teardown();
217     };
218 }