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 targetDOM: HTMLElement|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 const nodeKey = node.getKey();
42 let nodeDOM = this.context.editor.getElementByKey(nodeKey);
44 if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
45 nodeDOM = nodeDOM.firstElementChild as HTMLElement;
49 this.showForNode(node, nodeDOM);
54 onTargetDOMLoad(): void {
55 this.updateResizerPosition();
59 this.context.manager.offSelectionChange(this.onSelectionChange);
63 protected showForNode(node: NodeHasSize&LexicalNode, targetDOM: HTMLElement) {
64 this.resizerDOM = this.buildDOM();
65 this.targetDOM = targetDOM;
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'});
71 this.resizerDOM.append(ghost);
73 this.context.scrollDOM.append(this.resizerDOM);
74 this.updateResizerPosition();
76 this.mouseTracker = this.setupTracker(this.resizerDOM, node, targetDOM);
77 this.activeSelection = node.getKey();
79 if (targetDOM.matches('img, embed, iframe, object')) {
80 this.loadAbortController = new AbortController();
81 targetDOM.addEventListener('load', this.onTargetDOMLoad, { signal: this.loadAbortController.signal });
85 protected updateResizerPosition() {
86 if (!this.resizerDOM || !this.targetDOM) {
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;
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';
101 protected updateDOMSize(width: number, height: number): void {
102 if (!this.resizerDOM) {
106 this.resizerDOM.style.width = width + 'px';
107 this.resizerDOM.style.height = height + 'px';
111 this.mouseTracker?.teardown();
112 this.resizerDOM?.remove();
113 this.targetDOM = null;
114 this.activeSelection = '';
115 this.loadAbortController.abort();
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}`});
125 class: 'editor-node-resizer',
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;
135 let flipXChange: boolean = false;
136 let flipYChange: boolean = false;
138 const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => {
139 let xChange = distance.x;
141 xChange = 0 - xChange;
143 let yChange = distance.y;
145 yChange = 0 - yChange;
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);
154 return {width: newWidth, height: newHeight};
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()) {
167 startingRatio = startingHeight / startingWidth;
170 flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
171 flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne');
173 move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
174 const size = calculateSize(distance);
175 _this.updateDOMSize(size.width, size.height);
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();
187 _this.resizerDOM?.classList.remove('active');
194 export function registerNodeResizer(context: EditorUiContext): (() => void) {
195 const resizer = new NodeResizer(context);