diff --git a/packages/lexical-devtools/src/element-picker/element-overlay.ts b/packages/lexical-devtools/src/element-picker/element-overlay.ts new file mode 100644 index 00000000000..72d14b49c08 --- /dev/null +++ b/packages/lexical-devtools/src/element-picker/element-overlay.ts @@ -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'; + } +} diff --git a/packages/lexical-devtools/src/element-picker/element-picker.ts b/packages/lexical-devtools/src/element-picker/element-picker.ts new file mode 100644 index 00000000000..ae03dbec11e --- /dev/null +++ b/packages/lexical-devtools/src/element-picker/element-picker.ts @@ -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 = (el: HTMLElement) => T; +type ElementPickerOptions = { + parentElement?: Node; + useShadowDOM?: boolean; + onClick?: ElementCallback; + onHover?: ElementCallback; + elementFilter?: ElementCallback; +}; + +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); + } + } +} diff --git a/packages/lexical-devtools/src/element-picker/index.ts b/packages/lexical-devtools/src/element-picker/index.ts new file mode 100644 index 00000000000..bad087297d1 --- /dev/null +++ b/packages/lexical-devtools/src/element-picker/index.ts @@ -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}; diff --git a/packages/lexical-devtools/src/element-picker/utils.ts b/packages/lexical-devtools/src/element-picker/utils.ts new file mode 100644 index 00000000000..9a71d4d7bf5 --- /dev/null +++ b/packages/lexical-devtools/src/element-picker/utils.ts @@ -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, + }; +}; diff --git a/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx b/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx index 6eaf81c6e8b..e6cc9776725 100644 --- a/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx +++ b/packages/lexical-devtools/src/entrypoints/devtools-panel/App.tsx @@ -6,33 +6,25 @@ * */ -import type {IInjectedPegasusService} from '../injected/InjectedPegasusService'; -import type {EditorState} from 'lexical'; - import './App.css'; import { - Accordion, - AccordionButton, - AccordionIcon, - AccordionItem, - AccordionPanel, Alert, AlertIcon, Box, + ButtonGroup, Flex, Spacer, } from '@chakra-ui/react'; -import {TreeView} from '@lexical/devtools-core'; -import {getRPCService} from '@webext-pegasus/rpc'; import * as React from 'react'; -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import lexicalLogo from '@/public/lexical.svg'; import EditorsRefreshCTA from '../../components/EditorsRefreshCTA'; import {useExtensionStore} from '../../store'; -import {SerializedRawEditorState} from '../../types'; +import {EditorInspectorButton} from './components/EditorInspectorButton'; +import {EditorsList} from './components/EditorsList'; interface Props { tabID: number; @@ -45,31 +37,22 @@ function App({tabID}: Props) { const states = lexicalState[tabID] ?? {}; const lexicalCount = Object.keys(states ?? {}).length; - const injectedPegasusService = useMemo( - () => - getRPCService('InjectedPegasusService', { - context: 'window', - tabId: tabID, - }), - [tabID], - ); - return ( <> - - - Lexical logo + + - + + - - + {states === undefined ? ( Loading... ) : ( @@ -79,8 +62,17 @@ function App({tabID}: Props) { )} - - + + + + Lexical logo + {errorMessage !== '' ? ( @@ -89,50 +81,7 @@ function App({tabID}: Props) { {lexicalCount > 0 ? ( - - {Object.entries(states).map(([key, state]) => ( - -

- - - ID: {key} - - - -

- - - injectedPegasusService - .setEditorReadOnly(key, isReadonly) - .catch((e) => setErrorMessage(e.stack)) - } - editorState={state as EditorState} - setEditorState={(editorState) => - injectedPegasusService - .setEditorState( - key, - editorState as SerializedRawEditorState, - ) - .catch((e) => setErrorMessage(e.stack)) - } - generateContent={(exportDOM) => - injectedPegasusService.generateTreeViewContent( - key, - exportDOM, - ) - } - /> - -
- ))} -
+ ) : ( diff --git a/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx b/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx new file mode 100644 index 00000000000..fcbb980ffad --- /dev/null +++ b/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx @@ -0,0 +1,43 @@ +/** + * 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 {IconButton, Image} from '@chakra-ui/react'; +import {getRPCService} from '@webext-pegasus/rpc'; +import * as React from 'react'; + +import {IInjectedPegasusService} from '../../injected/InjectedPegasusService'; + +interface Props { + tabID: number; + setErrorMessage: (value: string) => void; +} + +export function EditorInspectorButton({tabID, setErrorMessage}: Props) { + const handleClick = () => { + const injectedPegasusService = getRPCService( + 'InjectedPegasusService', + {context: 'window', tabId: tabID}, + ); + + injectedPegasusService.toggleEditorPicker().catch((err) => { + setErrorMessage(err.message); + console.error(err); + }); + }; + + return ( + } + /> + ); +} diff --git a/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorsList.tsx b/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorsList.tsx new file mode 100644 index 00000000000..865062d456d --- /dev/null +++ b/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorsList.tsx @@ -0,0 +1,102 @@ +/** + * 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 type {IInjectedPegasusService} from '../../injected/InjectedPegasusService'; +import type {EditorState} from 'lexical'; + +// import './App.css'; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, +} from '@chakra-ui/react'; +import {TreeView} from '@lexical/devtools-core'; +import {getRPCService} from '@webext-pegasus/rpc'; +import * as React from 'react'; +import {useEffect, useMemo, useState} from 'react'; + +import {useExtensionStore} from '../../../store'; +import {SerializedRawEditorState} from '../../../types'; + +interface Props { + tabID: number; + setErrorMessage: (value: string) => void; +} + +export function EditorsList({tabID, setErrorMessage}: Props) { + const [expandedItems, setExpandedItems] = useState([0]); + const {lexicalState, selectedEditorKey} = useExtensionStore(); + const states = lexicalState[tabID] ?? {}; + const selectedEditorIdx = Object.keys(states).findIndex( + (key) => key === selectedEditorKey[tabID], + ); + + useEffect(() => { + if (selectedEditorIdx !== -1) { + setExpandedItems([selectedEditorIdx]); + } + }, [selectedEditorIdx]); + + const injectedPegasusService = useMemo( + () => + getRPCService('InjectedPegasusService', { + context: 'window', + tabId: tabID, + }), + [tabID], + ); + + return ( + + {Object.entries(states).map(([key, state]) => ( + +

+ + + ID: {key} + + + +

+ + + injectedPegasusService + .setEditorReadOnly(key, isReadonly) + .catch((e) => setErrorMessage(e.stack)) + } + editorState={state as EditorState} + setEditorState={(editorState) => + injectedPegasusService + .setEditorState(key, editorState as SerializedRawEditorState) + .catch((e) => setErrorMessage(e.stack)) + } + generateContent={(exportDOM) => + injectedPegasusService.generateTreeViewContent(key, exportDOM) + } + /> + +
+ ))} +
+ ); +} diff --git a/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts b/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts index 19f900843f4..176de0f1598 100644 --- a/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts +++ b/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts @@ -11,16 +11,20 @@ import {IPegasusRPCService, PegasusRPCMessage} from '@webext-pegasus/rpc'; import {LexicalEditor} from 'lexical'; import {StoreApi} from 'zustand'; +import {ElementPicker} from '../../element-picker'; import {readEditorState} from '../../lexicalForExtension'; import {deserializeEditorState} from '../../serializeEditorState'; import {ExtensionState} from '../../store'; import {SerializedRawEditorState} from '../../types'; +import {isLexicalNode} from '../../utils/isLexicalNode'; import scanAndListenForEditors from './scanAndListenForEditors'; import { queryLexicalEditorByKey, queryLexicalNodeByKey, } from './utils/queryLexicalByKey'; +const ELEMENT_PICKER_STYLE = {borderColor: '#0000ff'}; + export type IInjectedPegasusService = InstanceType< typeof InjectedPegasusService >; @@ -28,6 +32,8 @@ export type IInjectedPegasusService = InstanceType< export class InjectedPegasusService implements IPegasusRPCService { + private pickerActive: ElementPicker | null = null; + constructor( private readonly tabID: number, private readonly extensionStore: StoreApi, @@ -78,4 +84,40 @@ export class InjectedPegasusService editor.setEditorState(deserializeEditorState(editorState)); } + + toggleEditorPicker(): void { + if (this.pickerActive != null) { + this.pickerActive?.stop(); + this.pickerActive = null; + + return; + } + + this.pickerActive = new ElementPicker({style: ELEMENT_PICKER_STYLE}); + this.pickerActive.start({ + elementFilter: (el) => { + let parent: HTMLElement | null = el; + while (parent != null && parent.tagName !== 'BODY') { + if ('__lexicalEditor' in parent) { + return parent; + } + parent = parent.parentElement; + } + + return false; + }, + + onClick: (el) => { + this.pickerActive?.stop(); + this.pickerActive = null; + if (isLexicalNode(el)) { + this.extensionStore + .getState() + .setSelectedEditorKey(this.tabID, el.__lexicalEditor.getKey()); + } else { + console.warn('Selected Element is not a Lexical node'); + } + }, + }); + } } diff --git a/packages/lexical-devtools/src/entrypoints/injected/utils/queryLexicalNodes.ts b/packages/lexical-devtools/src/entrypoints/injected/utils/queryLexicalNodes.ts index bb635fa5a6f..4df2b4b350e 100644 --- a/packages/lexical-devtools/src/entrypoints/injected/utils/queryLexicalNodes.ts +++ b/packages/lexical-devtools/src/entrypoints/injected/utils/queryLexicalNodes.ts @@ -6,15 +6,10 @@ * */ import {LexicalHTMLElement} from '../../../types'; +import {isLexicalNode} from '../../../utils/isLexicalNode'; export default function queryLexicalNodes(): LexicalHTMLElement[] { return Array.from( document.querySelectorAll('div[data-lexical-editor]'), ).filter(isLexicalNode); } - -function isLexicalNode( - node: LexicalHTMLElement | Element, -): node is LexicalHTMLElement { - return (node as LexicalHTMLElement).__lexicalEditor !== undefined; -} diff --git a/packages/lexical-devtools/src/public/inspect.svg b/packages/lexical-devtools/src/public/inspect.svg new file mode 100644 index 00000000000..aee18e2be47 --- /dev/null +++ b/packages/lexical-devtools/src/public/inspect.svg @@ -0,0 +1,10 @@ + + + + diff --git a/packages/lexical-devtools/src/store.ts b/packages/lexical-devtools/src/store.ts index a447f8c9565..8b38b0a475b 100644 --- a/packages/lexical-devtools/src/store.ts +++ b/packages/lexical-devtools/src/store.ts @@ -19,15 +19,27 @@ export interface ExtensionState { lexicalState: { [tabID: number]: {[editorKey: string]: SerializedRawEditorState}; }; + selectedEditorKey: { + [tabID: number]: string | null; + }; setStatesForTab: ( id: number, states: {[editorKey: string]: SerializedRawEditorState}, ) => void; + setSelectedEditorKey: (tabID: number, editorKey: string | null) => void; } export const useExtensionStore = create()( subscribeWithSelector((set) => ({ lexicalState: {}, + selectedEditorKey: {}, + setSelectedEditorKey: (tabID: number, editorKey: string | null) => + set((state) => ({ + selectedEditorKey: { + ...state.selectedEditorKey, + [tabID]: editorKey, + }, + })), setStatesForTab: ( id: number, states: {[editorKey: string]: SerializedRawEditorState}, diff --git a/packages/lexical-devtools/src/utils/isLexicalNode.ts b/packages/lexical-devtools/src/utils/isLexicalNode.ts new file mode 100644 index 00000000000..f3c3b52f6c9 --- /dev/null +++ b/packages/lexical-devtools/src/utils/isLexicalNode.ts @@ -0,0 +1,15 @@ +/** + * 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 {LexicalHTMLElement} from '../types'; + +export function isLexicalNode( + node: LexicalHTMLElement | Element, +): node is LexicalHTMLElement { + return (node as LexicalHTMLElement).__lexicalEditor !== undefined; +}