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