1 import {BaseSelection, LexicalNode,} from "lexical";
2 import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker";
3 import {el} from "../../../utils/dom";
4 import {$isImageNode} from "../../../nodes/image";
5 import {EditorUiContext} from "../core";
6 import {NodeHasSize} from "../../../nodes/_common";
7 import {$isMediaNode} from "../../../nodes/media";
9 function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode {
10 return $isImageNode(node) || $isMediaNode(node);
14 protected context: EditorUiContext;
15 protected dom: HTMLElement|null = null;
16 protected scrollContainer: HTMLElement;
18 protected mouseTracker: MouseDragTracker|null = null;
19 protected activeSelection: string = '';
21 constructor(context: EditorUiContext) {
22 this.context = context;
23 this.scrollContainer = context.scrollDOM;
25 this.onSelectionChange = this.onSelectionChange.bind(this);
26 context.manager.onSelectionChange(this.onSelectionChange);
29 onSelectionChange(selection: BaseSelection|null) {
30 const nodes = selection?.getNodes() || [];
31 if (this.activeSelection) {
35 if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
36 const node = nodes[0];
37 const nodeKey = node.getKey();
38 let nodeDOM = this.context.editor.getElementByKey(nodeKey);
40 if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
41 nodeDOM = nodeDOM.firstElementChild as HTMLElement;
45 this.showForNode(node, nodeDOM);
51 this.context.manager.offSelectionChange(this.onSelectionChange);
55 protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) {
56 this.dom = this.buildDOM();
58 let ghost = el('span', {class: 'editor-node-resizer-ghost'});
59 if ($isImageNode(node)) {
60 ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'});
62 this.dom.append(ghost);
64 this.context.scrollDOM.append(this.dom);
65 this.updateDOMPosition(dom);
67 this.mouseTracker = this.setupTracker(this.dom, node, dom);
68 this.activeSelection = node.getKey();
71 protected updateDOMPosition(nodeDOM: HTMLElement) {
76 const scrollAreaRect = this.scrollContainer.getBoundingClientRect();
77 const nodeRect = nodeDOM.getBoundingClientRect();
78 const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop);
79 const left = nodeRect.left - scrollAreaRect.left;
81 this.dom.style.top = `${top}px`;
82 this.dom.style.left = `${left}px`;
83 this.dom.style.width = nodeRect.width + 'px';
84 this.dom.style.height = nodeRect.height + 'px';
87 protected updateDOMSize(width: number, height: number): void {
92 this.dom.style.width = width + 'px';
93 this.dom.style.height = height + 'px';
97 this.mouseTracker?.teardown();
99 this.activeSelection = '';
102 protected buildDOM() {
103 const handleClasses = ['nw', 'ne', 'se', 'sw'];
104 const handleElems = handleClasses.map(c => {
105 return el('div', {class: `editor-node-resizer-handle ${c}`});
109 class: 'editor-node-resizer',
113 setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker {
114 let startingWidth: number = 0;
115 let startingHeight: number = 0;
116 let startingRatio: number = 0;
117 let hasHeight = false;
119 let flipXChange: boolean = false;
120 let flipYChange: boolean = false;
122 const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => {
123 let xChange = distance.x;
125 xChange = 0 - xChange;
127 let yChange = distance.y;
129 yChange = 0 - yChange;
132 const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2));
133 const increase = xChange + yChange > 0;
134 const directedChange = increase ? balancedChange : 0-balancedChange;
135 const newWidth = Math.max(5, Math.round(startingWidth + directedChange));
136 const newHeight = Math.round(newWidth * startingRatio);
138 return {width: newWidth, height: newHeight};
141 return new MouseDragTracker(container, '.editor-node-resizer-handle', {
142 down(event: MouseEvent, handle: HTMLElement) {
143 _this.dom?.classList.add('active');
144 _this.context.editor.getEditorState().read(() => {
145 const domRect = nodeDOM.getBoundingClientRect();
146 startingWidth = node.getWidth() || domRect.width;
147 startingHeight = node.getHeight() || domRect.height;
148 if (node.getHeight()) {
151 startingRatio = startingHeight / startingWidth;
154 flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
155 flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne');
157 move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
158 const size = calculateSize(distance);
159 _this.updateDOMSize(size.width, size.height);
161 up(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
162 const size = calculateSize(distance);
163 _this.context.editor.update(() => {
164 node.setWidth(size.width);
165 node.setHeight(hasHeight ? size.height : 0);
166 _this.context.manager.triggerLayoutUpdate();
167 requestAnimationFrame(() => {
168 _this.updateDOMPosition(nodeDOM);
171 _this.dom?.classList.remove('active');
178 export function registerNodeResizer(context: EditorUiContext): (() => void) {
179 const resizer = new NodeResizer(context);