]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts
2e4f2939ca2a2d78029aa65f4631855b889082b4
[bookstack] / resources / js / wysiwyg / ui / framework / helpers / node-resizer.ts
1 import {BaseSelection, LexicalNode,} from "lexical";
2 import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker";
3 import {el} from "../../../utils/dom";
4 import {$isImageNode} from "../../../nodes/image";
5 import {EditorUiContext} from "../core";
6 import {NodeHasSize} from "../../../nodes/_common";
7 import {$isMediaNode} from "../../../nodes/media";
8
9 function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode {
10     return $isImageNode(node) || $isMediaNode(node);
11 }
12
13 class NodeResizer {
14     protected context: EditorUiContext;
15     protected dom: HTMLElement|null = null;
16     protected scrollContainer: HTMLElement;
17
18     protected mouseTracker: MouseDragTracker|null = null;
19     protected activeSelection: string = '';
20
21     constructor(context: EditorUiContext) {
22         this.context = context;
23         this.scrollContainer = context.scrollDOM;
24
25         this.onSelectionChange = this.onSelectionChange.bind(this);
26         context.manager.onSelectionChange(this.onSelectionChange);
27     }
28
29     onSelectionChange(selection: BaseSelection|null) {
30         const nodes = selection?.getNodes() || [];
31         if (this.activeSelection) {
32             this.hide();
33         }
34
35         if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
36             const node = nodes[0];
37             const nodeKey = node.getKey();
38             let nodeDOM = this.context.editor.getElementByKey(nodeKey);
39
40             if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
41                 nodeDOM = nodeDOM.firstElementChild as HTMLElement;
42             }
43
44             if (nodeDOM) {
45                 this.showForNode(node, nodeDOM);
46             }
47         }
48     }
49
50     teardown() {
51         this.context.manager.offSelectionChange(this.onSelectionChange);
52         this.hide();
53     }
54
55     protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) {
56         this.dom = this.buildDOM();
57
58         let ghost = el('span', {class: 'editor-node-resizer-ghost'});
59         if ($isImageNode(node)) {
60             ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'});
61         }
62         this.dom.append(ghost);
63
64         this.context.scrollDOM.append(this.dom);
65         this.updateDOMPosition(dom);
66
67         this.mouseTracker = this.setupTracker(this.dom, node, dom);
68         this.activeSelection = node.getKey();
69     }
70
71     protected updateDOMPosition(nodeDOM: HTMLElement) {
72         if (!this.dom) {
73             return;
74         }
75
76         const scrollAreaRect = this.scrollContainer.getBoundingClientRect();
77         const nodeRect = nodeDOM.getBoundingClientRect();
78         const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop);
79         const left = nodeRect.left - scrollAreaRect.left;
80
81         this.dom.style.top = `${top}px`;
82         this.dom.style.left = `${left}px`;
83         this.dom.style.width = nodeRect.width + 'px';
84         this.dom.style.height = nodeRect.height + 'px';
85     }
86
87     protected updateDOMSize(width: number, height: number): void {
88         if (!this.dom) {
89             return;
90         }
91
92         this.dom.style.width = width + 'px';
93         this.dom.style.height = height + 'px';
94     }
95
96     protected hide() {
97         this.mouseTracker?.teardown();
98         this.dom?.remove();
99         this.activeSelection = '';
100     }
101
102     protected buildDOM() {
103         const handleClasses = ['nw', 'ne', 'se', 'sw'];
104         const handleElems = handleClasses.map(c => {
105             return el('div', {class: `editor-node-resizer-handle ${c}`});
106         });
107
108         return el('div', {
109             class: 'editor-node-resizer',
110         }, handleElems);
111     }
112
113     setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker {
114         let startingWidth: number = 0;
115         let startingHeight: number = 0;
116         let startingRatio: number = 0;
117         let hasHeight = false;
118         let _this = this;
119         let flipXChange: boolean = false;
120         let flipYChange: boolean = false;
121
122         const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => {
123             let xChange = distance.x;
124             if (flipXChange) {
125                 xChange = 0 - xChange;
126             }
127             let yChange = distance.y;
128             if (flipYChange) {
129                 yChange = 0 - yChange;
130             }
131
132             const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2));
133             const increase = xChange + yChange > 0;
134             const directedChange = increase ? balancedChange : 0-balancedChange;
135             const newWidth = Math.max(5, Math.round(startingWidth + directedChange));
136             const newHeight = Math.round(newWidth * startingRatio);
137
138             return {width: newWidth, height: newHeight};
139         };
140
141         return new MouseDragTracker(container, '.editor-node-resizer-handle', {
142             down(event: MouseEvent, handle: HTMLElement) {
143                 _this.dom?.classList.add('active');
144                 _this.context.editor.getEditorState().read(() => {
145                     const domRect = nodeDOM.getBoundingClientRect();
146                     startingWidth = node.getWidth() || domRect.width;
147                     startingHeight = node.getHeight() || domRect.height;
148                     if (node.getHeight()) {
149                         hasHeight = true;
150                     }
151                     startingRatio = startingHeight / startingWidth;
152                 });
153
154                 flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
155                 flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne');
156             },
157             move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
158                 const size = calculateSize(distance);
159                 _this.updateDOMSize(size.width, size.height);
160             },
161             up(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
162                 const size = calculateSize(distance);
163                 _this.context.editor.update(() => {
164                     node.setWidth(size.width);
165                     node.setHeight(hasHeight ? size.height : 0);
166                     _this.context.manager.triggerLayoutUpdate();
167                     requestAnimationFrame(() => {
168                         _this.updateDOMPosition(nodeDOM);
169                     })
170                 });
171                 _this.dom?.classList.remove('active');
172             }
173         });
174     }
175 }
176
177
178 export function registerNodeResizer(context: EditorUiContext): (() => void) {
179     const resizer = new NodeResizer(context);
180
181     return () => {
182         resizer.teardown();
183     };
184 }