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