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