Skip to content

Commit

Permalink
feat(@lexical/devtools): Added interactive editor picker (facebook#5926)
Browse files Browse the repository at this point in the history
  • Loading branch information
StyleT authored Apr 23, 2024
1 parent da06d8f commit 25b3579
Show file tree
Hide file tree
Showing 12 changed files with 506 additions and 84 deletions.
71 changes: 71 additions & 0 deletions packages/lexical-devtools/src/element-picker/element-overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {BoundingBox, ElementOverlayOptions} from './utils';

export default class ElementOverlay {
overlay: HTMLDivElement;
shadowContainer: HTMLDivElement;
shadowRoot: ShadowRoot;
usingShadowDOM?: boolean;

constructor(options: ElementOverlayOptions) {
this.overlay = document.createElement('div');
this.overlay.className = options.className || '_ext-element-overlay';
this.overlay.style.background =
options.style?.background || 'rgba(250, 240, 202, 0.2)';
this.overlay.style.borderColor = options.style?.borderColor || '#F95738';
this.overlay.style.borderStyle = options.style?.borderStyle || 'solid';
this.overlay.style.borderRadius = options.style?.borderRadius || '1px';
this.overlay.style.borderWidth = options.style?.borderWidth || '1px';
this.overlay.style.boxSizing = options.style?.boxSizing || 'border-box';
this.overlay.style.cursor = options.style?.cursor || 'crosshair';
this.overlay.style.position = options.style?.position || 'absolute';
this.overlay.style.zIndex = options.style?.zIndex || '2147483647';

this.shadowContainer = document.createElement('div');
this.shadowContainer.className = '_ext-element-overlay-container';
this.shadowContainer.style.position = 'absolute';
this.shadowContainer.style.top = '0px';
this.shadowContainer.style.left = '0px';
this.shadowRoot = this.shadowContainer.attachShadow({mode: 'open'});
}

addToDOM(parent: Node, useShadowDOM: boolean) {
this.usingShadowDOM = useShadowDOM;
if (useShadowDOM) {
parent.insertBefore(this.shadowContainer, parent.firstChild);
this.shadowRoot.appendChild(this.overlay);
} else {
parent.appendChild(this.overlay);
}
}

removeFromDOM() {
this.setBounds({height: 0, width: 0, x: 0, y: 0});
this.overlay.remove();
if (this.usingShadowDOM) {
this.shadowContainer.remove();
}
}

captureCursor() {
this.overlay.style.pointerEvents = 'auto';
}

ignoreCursor() {
this.overlay.style.pointerEvents = 'none';
}

setBounds({x, y, width, height}: BoundingBox) {
this.overlay.style.left = x + 'px';
this.overlay.style.top = y + 'px';
this.overlay.style.width = width + 'px';
this.overlay.style.height = height + 'px';
}
}
131 changes: 131 additions & 0 deletions packages/lexical-devtools/src/element-picker/element-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import ElementOverlay from './element-overlay';
import {ElementOverlayOptions, getElementBounds} from './utils';

type ElementCallback<T> = (el: HTMLElement) => T;
type ElementPickerOptions = {
parentElement?: Node;
useShadowDOM?: boolean;
onClick?: ElementCallback<void>;
onHover?: ElementCallback<void>;
elementFilter?: ElementCallback<boolean | HTMLElement>;
};

export default class ElementPicker {
private overlay: ElementOverlay;
private active: boolean;
private options?: ElementPickerOptions;
private target?: HTMLElement;
private mouseX?: number;
private mouseY?: number;
private tickReq?: number;

constructor(overlayOptions?: ElementOverlayOptions) {
this.active = false;
this.overlay = new ElementOverlay(overlayOptions ?? {});
}

start(options: ElementPickerOptions): boolean {
if (this.active) {
return false;
}

this.active = true;
this.options = options;
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('click', this.handleClick, true);

this.overlay.addToDOM(
options.parentElement ?? document.body,
options.useShadowDOM ?? true,
);

this.tick();

return true;
}

stop() {
this.active = false;
this.options = undefined;
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('click', this.handleClick, true);

this.overlay.removeFromDOM();
this.target = undefined;
this.mouseX = undefined;
this.mouseY = undefined;

if (this.tickReq) {
window.cancelAnimationFrame(this.tickReq);
}
}

private handleMouseMove = (event: MouseEvent) => {
this.mouseX = event.clientX;
this.mouseY = event.clientY;
};

private handleClick = (event: MouseEvent) => {
if (this.target && this.options?.onClick) {
this.options.onClick(this.target);
}
event.preventDefault();
};

private tick = () => {
this.updateTarget();
this.tickReq = window.requestAnimationFrame(this.tick);
};

private updateTarget() {
if (this.mouseX === undefined || this.mouseY === undefined) {
return;
}

// Peek through the overlay to find the new target
this.overlay.ignoreCursor();
const elAtCursor = document.elementFromPoint(this.mouseX, this.mouseY);
let newTarget = elAtCursor as HTMLElement;
this.overlay.captureCursor();

// If the target hasn't changed, there's nothing to do
if (!newTarget || newTarget === this.target) {
return;
}

// If we have an element filter and the new target doesn't match,
// clear out the target
if (this.options?.elementFilter) {
const filterResult = this.options.elementFilter(newTarget);
if (filterResult === false) {
this.target = undefined;
this.overlay.setBounds({height: 0, width: 0, x: 0, y: 0});
return;
}
// If the filter returns an element, use that element as new target
else if (typeof filterResult !== 'boolean') {
if (filterResult === this.target) {
return;
}
newTarget = filterResult;
}
}

this.target = newTarget;

const bounds = getElementBounds(newTarget);
this.overlay.setBounds(bounds);

if (this.options?.onHover) {
this.options.onHover(newTarget);
}
}
}
11 changes: 11 additions & 0 deletions packages/lexical-devtools/src/element-picker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import ElementPicker from './element-picker';

export {ElementPicker};
41 changes: 41 additions & 0 deletions packages/lexical-devtools/src/element-picker/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}

export interface ElementOverlayStyleOptions {
background?: string;
borderColor?: string;
borderStyle?: string;
borderRadius?: string;
borderWidth?: string;
boxSizing?: string;
cursor?: string;
position?: string;
zIndex?: string;
}

export type ElementOverlayOptions = {
className?: string;
style?: ElementOverlayStyleOptions;
};

export const getElementBounds = (el: HTMLElement): BoundingBox => {
const rect = el.getBoundingClientRect();
return {
height: el.offsetHeight,
width: el.offsetWidth,
x: window.pageXOffset + rect.left,
y: window.pageYOffset + rect.top,
};
};
Loading

0 comments on commit 25b3579

Please sign in to comment.