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