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";
9 function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode {
10 return $isImageNode(node) || $isMediaNode(node);
14 protected context: EditorUiContext;
15 protected resizerDOM: HTMLElement|null = null;
16 protected targetNode: LexicalNode|null = null;
17 protected scrollContainer: HTMLElement;
19 protected mouseTracker: MouseDragTracker|null = null;
20 protected activeSelection: string = '';
21 protected loadAbortController = new AbortController();
23 constructor(context: EditorUiContext) {
24 this.context = context;
25 this.scrollContainer = context.scrollDOM;
27 this.onSelectionChange = this.onSelectionChange.bind(this);
28 this.onTargetDOMLoad = this.onTargetDOMLoad.bind(this);
30 context.manager.onSelectionChange(this.onSelectionChange);
33 onSelectionChange(selection: BaseSelection|null) {
34 const nodes = selection?.getNodes() || [];
35 if (this.activeSelection) {
39 if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
40 const node = nodes[0];
41 let nodeDOM = this.getTargetDOM(node)
44 this.showForNode(node, nodeDOM);
49 protected getTargetDOM(targetNode: LexicalNode|null): HTMLElement|null {
50 if (targetNode == null) {
54 let nodeDOM = this.context.editor.getElementByKey(targetNode.__key)
55 if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
56 nodeDOM = nodeDOM.firstElementChild as HTMLElement;
61 protected onTargetDOMLoad(): void {
62 this.updateResizerPosition();
66 this.context.manager.offSelectionChange(this.onSelectionChange);
70 protected showForNode(node: NodeHasSize&LexicalNode, targetDOM: HTMLElement) {
71 this.resizerDOM = this.buildDOM();
72 this.targetNode = node;
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'});
78 this.resizerDOM.append(ghost);
80 this.context.scrollDOM.append(this.resizerDOM);
81 this.updateResizerPosition();
83 this.mouseTracker = this.setupTracker(this.resizerDOM, node, targetDOM);
84 this.activeSelection = node.getKey();
86 if (targetDOM.matches('img, embed, iframe, object')) {
87 this.loadAbortController = new AbortController();
88 targetDOM.addEventListener('load', this.onTargetDOMLoad, { signal: this.loadAbortController.signal });
92 protected updateResizerPosition() {
93 const targetDOM = this.getTargetDOM(this.targetNode);
94 if (!this.resizerDOM || !targetDOM) {
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;
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';
109 protected updateDOMSize(width: number, height: number): void {
110 if (!this.resizerDOM) {
114 this.resizerDOM.style.width = width + 'px';
115 this.resizerDOM.style.height = height + 'px';
119 this.mouseTracker?.teardown();
120 this.resizerDOM?.remove();
121 this.targetNode = null;
122 this.activeSelection = '';
123 this.loadAbortController.abort();
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}`});
133 class: 'editor-node-resizer',
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;
143 let flipXChange: boolean = false;
144 let flipYChange: boolean = false;
146 const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => {
147 let xChange = distance.x;
149 xChange = 0 - xChange;
151 let yChange = distance.y;
153 yChange = 0 - yChange;
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);
162 return {width: newWidth, height: newHeight};
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()) {
175 startingRatio = startingHeight / startingWidth;
178 flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
179 flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne');
181 move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
182 const size = calculateSize(distance);
183 _this.updateDOMSize(size.width, size.height);
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);
192 requestAnimationFrame(() => {
193 _this.context.manager.triggerLayoutUpdate();
194 _this.updateResizerPosition();
198 _this.resizerDOM?.classList.remove('active');
205 export function registerNodeResizer(context: EditorUiContext): (() => void) {
206 const resizer = new NodeResizer(context);