From 25b357949c04a2cdb41b82269bd49493cafed029 Mon Sep 17 00:00:00 2001 From: Vlad Fedosov Date: Mon, 22 Apr 2024 20:44:49 -0400 Subject: [PATCH 1/2] feat(@lexical/devtools): Added interactive editor picker (#5926) --- .../src/element-picker/element-overlay.ts | 71 ++++++++++ .../src/element-picker/element-picker.ts | 131 ++++++++++++++++++ .../src/element-picker/index.ts | 11 ++ .../src/element-picker/utils.ts | 41 ++++++ .../src/entrypoints/devtools-panel/App.tsx | 105 ++++---------- .../components/EditorInspectorButton.tsx | 43 ++++++ .../devtools-panel/components/EditorsList.tsx | 102 ++++++++++++++ .../injected/InjectedPegasusService.ts | 42 ++++++ .../injected/utils/queryLexicalNodes.ts | 7 +- .../lexical-devtools/src/public/inspect.svg | 10 ++ packages/lexical-devtools/src/store.ts | 12 ++ .../src/utils/isLexicalNode.ts | 15 ++ 12 files changed, 506 insertions(+), 84 deletions(-) create mode 100644 packages/lexical-devtools/src/element-picker/element-overlay.ts create mode 100644 packages/lexical-devtools/src/element-picker/element-picker.ts create mode 100644 packages/lexical-devtools/src/element-picker/index.ts create mode 100644 packages/lexical-devtools/src/element-picker/utils.ts create mode 100644 packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx create mode 100644 packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorsList.tsx create mode 100644 packages/lexical-devtools/src/public/inspect.svg create mode 100644 packages/lexical-devtools/src/utils/isLexicalNode.ts 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; +} From 0b02af5c7a5bd927f252428ab5892b9d62411566 Mon Sep 17 00:00:00 2001 From: Vlad Fedosov Date: Tue, 23 Apr 2024 11:07:20 -0400 Subject: [PATCH 2/2] fix(@lexical/devtools): Misc fixes before first public release (#5942) - feat(@lexical/devtools): Correct handling of the restricted tabs - fix(@lexical/devtools): Now we refresh editors before starting interactive selection - fix(@lexical/devtools): Fixed work of the $isElementNode wrapper in websites that rely on "babelHelpers.inheritsLoose" --- packages/lexical-devtools/README.md | 1 + .../src/components/EditorsRefreshCTA.tsx | 2 +- .../background/ActionIconWatchdog.ts | 100 ++++++++++++++++++ .../src/entrypoints/background/index.ts | 8 +- .../components/EditorInspectorButton.tsx | 11 +- .../injected/InjectedPegasusService.ts | 2 +- .../src/entrypoints/popup/App.css | 14 --- .../src/entrypoints/popup/App.tsx | 65 +++++++----- .../src/lexicalForExtension.ts | 16 ++- .../src/public/icon/128-restricted.png | Bin 0 -> 2113 bytes .../src/public/icon/16-restricted.png | Bin 0 -> 428 bytes .../src/public/icon/32-restricted.png | Bin 0 -> 813 bytes .../src/public/icon/48-restricted.png | Bin 0 -> 1025 bytes packages/lexical-devtools/src/store.ts | 10 +- 14 files changed, 174 insertions(+), 55 deletions(-) create mode 100644 packages/lexical-devtools/src/entrypoints/background/ActionIconWatchdog.ts create mode 100644 packages/lexical-devtools/src/public/icon/128-restricted.png create mode 100644 packages/lexical-devtools/src/public/icon/16-restricted.png create mode 100644 packages/lexical-devtools/src/public/icon/32-restricted.png create mode 100644 packages/lexical-devtools/src/public/icon/48-restricted.png diff --git a/packages/lexical-devtools/README.md b/packages/lexical-devtools/README.md index 7e1b7b579d8..74d27cb9925 100644 --- a/packages/lexical-devtools/README.md +++ b/packages/lexical-devtools/README.md @@ -16,6 +16,7 @@ $ npm run dev - Extension activity log: [chrome://extensions/?activity=eddfjidloofnnmloonifcjkpmfmlblab](chrome://extensions/?activity=eddfjidloofnnmloonifcjkpmfmlblab) - Status of ServiceWorkers: [chrome://serviceworker-internals/?devtools](chrome://serviceworker-internals/?devtools) - WXT Framework debugging: `DEBUG_WXT=1 npm run dev` +- If you detach the Dev Tools in a separate window, and press `Cmd+Option+I` while Dev Tools window is focused, you will invoke the Dev Tools for the Dev Tools window. ## Design diff --git a/packages/lexical-devtools/src/components/EditorsRefreshCTA.tsx b/packages/lexical-devtools/src/components/EditorsRefreshCTA.tsx index 22bb8d1373b..01986bf2b88 100644 --- a/packages/lexical-devtools/src/components/EditorsRefreshCTA.tsx +++ b/packages/lexical-devtools/src/components/EditorsRefreshCTA.tsx @@ -30,7 +30,7 @@ function EditorsRefreshCTA({tabID, setErrorMessage}: Props) { ); injectedPegasusService - .refreshLexicalEditorsForTabID() + .refreshLexicalEditors() .catch((err) => { setErrorMessage(err.message); console.error(err); diff --git a/packages/lexical-devtools/src/entrypoints/background/ActionIconWatchdog.ts b/packages/lexical-devtools/src/entrypoints/background/ActionIconWatchdog.ts new file mode 100644 index 00000000000..124d67f54b7 --- /dev/null +++ b/packages/lexical-devtools/src/entrypoints/background/ActionIconWatchdog.ts @@ -0,0 +1,100 @@ +/** + * 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 {Tabs} from 'wxt/browser'; +import type {StoreApi} from 'zustand'; + +import {IS_FIREFOX} from 'shared/environment'; + +import {ExtensionState} from '../../store'; + +export default class ActionIconWatchdog { + private constructor( + private readonly extensionStore: StoreApi, + ) {} + + static async start(store: StoreApi) { + return new ActionIconWatchdog(store).init(); + } + + async init() { + const tabs = await browser.tabs.query({}); + await Promise.all( + tabs.map(this.checkAndHandleRestrictedPageIfSo.bind(this)), + ); + + browser.tabs.onCreated.addListener((tab) => { + this.checkAndHandleRestrictedPageIfSo(tab); + }); + + // Listen to URL changes on the active tab and update the DevTools icon. + browser.tabs.onUpdated.addListener(this.handleTabsUpdatedEvent.bind(this)); + } + + private async setIcon( + lexicalBuildType: 'restricted' | 'enabled', + tabId: number, + ) { + const action = IS_FIREFOX ? browser.browserAction : browser.action; + + await action.setIcon({ + path: { + '128': browser.runtime.getURL( + lexicalBuildType === 'enabled' + ? '/icon/128.png' + : '/icon/128-restricted.png', + ), + '16': browser.runtime.getURL( + lexicalBuildType === 'enabled' + ? '/icon/16.png' + : '/icon/16-restricted.png', + ), + '32': browser.runtime.getURL( + lexicalBuildType === 'enabled' + ? '/icon/32.png' + : '/icon/32-restricted.png', + ), + '48': browser.runtime.getURL( + lexicalBuildType === 'enabled' + ? '/icon/48.png' + : '/icon/48-restricted.png', + ), + }, + tabId: tabId, + }); + + if (lexicalBuildType === 'restricted') { + this.extensionStore.getState().markTabAsRestricted(tabId); + } + } + + private handleTabsUpdatedEvent( + tabId: number, + _changeInfo: unknown, + tab: Tabs.Tab, + ): void { + this.checkAndHandleRestrictedPageIfSo(tab); + } + + private isRestrictedBrowserPage(url: string | undefined) { + return ( + !url || ['chrome:', 'about:', 'file:'].includes(new URL(url).protocol) + ); + } + + private async checkAndHandleRestrictedPageIfSo(tab: Tabs.Tab) { + if (tab.id == null) { + return; + } + + if (tab.id == null || this.isRestrictedBrowserPage(tab.url)) { + return this.setIcon('restricted', tab.id); + } + + return this.setIcon('enabled', tab.id); + } +} diff --git a/packages/lexical-devtools/src/entrypoints/background/index.ts b/packages/lexical-devtools/src/entrypoints/background/index.ts index d0c1de4e14e..4dd130799d4 100644 --- a/packages/lexical-devtools/src/entrypoints/background/index.ts +++ b/packages/lexical-devtools/src/entrypoints/background/index.ts @@ -9,7 +9,11 @@ import {registerRPCService} from '@webext-pegasus/rpc'; import {initPegasusTransport} from '@webext-pegasus/transport/background'; -import {initExtensionStoreBackend} from '../../store.ts'; +import { + initExtensionStoreBackend, + useExtensionStore as extensionStore, +} from '../../store.ts'; +import ActionIconWatchdog from './ActionIconWatchdog.ts'; import {getTabIDService} from './getTabIDService'; export default defineBackground(() => { @@ -20,4 +24,6 @@ export default defineBackground(() => { // Store initialization so other extension surfaces can use it // as all changes go through background SW initExtensionStoreBackend(); + + ActionIconWatchdog.start(extensionStore).catch(console.error); }); diff --git a/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx b/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx index fcbb980ffad..d1019b92303 100644 --- a/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx +++ b/packages/lexical-devtools/src/entrypoints/devtools-panel/components/EditorInspectorButton.tsx @@ -24,10 +24,13 @@ export function EditorInspectorButton({tabID, setErrorMessage}: Props) { {context: 'window', tabId: tabID}, ); - injectedPegasusService.toggleEditorPicker().catch((err) => { - setErrorMessage(err.message); - console.error(err); - }); + injectedPegasusService + .refreshLexicalEditors() + .then(() => injectedPegasusService.toggleEditorPicker()) + .catch((err) => { + setErrorMessage(err.message); + console.error(err); + }); }; return ( diff --git a/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts b/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts index 176de0f1598..664006f0fb1 100644 --- a/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts +++ b/packages/lexical-devtools/src/entrypoints/injected/InjectedPegasusService.ts @@ -40,7 +40,7 @@ export class InjectedPegasusService private readonly commandLog: WeakMap, ) {} - refreshLexicalEditorsForTabID() { + refreshLexicalEditors() { scanAndListenForEditors(this.tabID, this.extensionStore, this.commandLog); } diff --git a/packages/lexical-devtools/src/entrypoints/popup/App.css b/packages/lexical-devtools/src/entrypoints/popup/App.css index 72a3cbc4bf1..e6bd89156df 100644 --- a/packages/lexical-devtools/src/entrypoints/popup/App.css +++ b/packages/lexical-devtools/src/entrypoints/popup/App.css @@ -17,17 +17,3 @@ body { margin: 0 auto; padding: 1rem; } - -.logo { - height: 2em; - /* padding: 1.5em; */ - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -.card { - padding-top: 2em; -} diff --git a/packages/lexical-devtools/src/entrypoints/popup/App.tsx b/packages/lexical-devtools/src/entrypoints/popup/App.tsx index 2887b4f1f04..05733be0c3d 100644 --- a/packages/lexical-devtools/src/entrypoints/popup/App.tsx +++ b/packages/lexical-devtools/src/entrypoints/popup/App.tsx @@ -7,11 +7,10 @@ */ import './App.css'; +import {Box, Flex} from '@chakra-ui/react'; import * as React from 'react'; import {useState} from 'react'; -import lexicalLogo from '@/public/lexical.svg'; - import EditorsRefreshCTA from '../../components/EditorsRefreshCTA'; import {useExtensionStore} from '../../store'; @@ -27,38 +26,46 @@ function App({tabID}: Props) { const lexicalCount = Object.keys(states ?? {}).length; return ( - <> -
- - Lexical logo - -
+ {errorMessage !== '' ? ( -
{errorMessage}
+ + {errorMessage} + ) : null} -
- {states === undefined ? ( - Loading... - ) : ( + + {states === null ? ( - Found {lexicalCount} editor{lexicalCount > 1 ? 's' : ''} on - the page - {lexicalCount > 0 ? ( - <> - {' '} - ✅ -
- Open the developer tools, and "Lexical" tab will appear to the - right. - - ) : null} + This is a restricted browser page. Lexical DevTools cannot access + this page.
+ ) : states === undefined ? ( + Loading... + ) : ( + <> + + Found {lexicalCount} editor + {lexicalCount > 1 || lexicalCount === 0 ? 's' : ''} on the page + {lexicalCount > 0 ? ( + <> + {' '} + ✅ +
+ Open the developer tools, and "Lexical" tab will appear to the + right. + + ) : null} +
+ + + + + )} -

- -

-
- +
+ ); } diff --git a/packages/lexical-devtools/src/lexicalForExtension.ts b/packages/lexical-devtools/src/lexicalForExtension.ts index bdd537f3d1e..3b45ed74f52 100644 --- a/packages/lexical-devtools/src/lexicalForExtension.ts +++ b/packages/lexical-devtools/src/lexicalForExtension.ts @@ -59,17 +59,25 @@ export function $getSelection(): null | lexical.BaseSelection { export function $isElementNode( node: lexical.LexicalNode | null | undefined, ): node is lexical.ElementNode { + if (node == null) { + return false; + } + const editor = getActiveEditor(); - const ElementNode = Object.getPrototypeOf( - editor._nodes.get('paragraph')!.klass, - ); + const ParagraphNode = editor._nodes.get('paragraph')!.klass; + const ElementNode = Object.getPrototypeOf(ParagraphNode.prototype); - return node instanceof ElementNode; + // eslint-disable-next-line no-prototype-builtins + return ElementNode.isPrototypeOf(node); } export function $isTextNode( node: lexical.LexicalNode | null | undefined, ): node is lexical.TextNode { + if (node == null) { + return false; + } + const editor = getActiveEditor(); const TextNode = editor._nodes.get('text')!.klass; diff --git a/packages/lexical-devtools/src/public/icon/128-restricted.png b/packages/lexical-devtools/src/public/icon/128-restricted.png new file mode 100644 index 0000000000000000000000000000000000000000..781567d2fbef7d1964eaf1b6fd05fd0abc62072a GIT binary patch literal 2113 zcmZvec{J1w7sr3b+Q>UIVF*bigoX^2Efr(QPL?DiGLkhOyrVJpX7r%OQA)$38e6uk zg9k&?Akv6R#!Qp4rR-y!>3ulw^S=G@zJJ_%&pqdU@44sPb3XSHogJ;EzzSdh0Hkbf zEM0eE(ymE}?f4hw{{1^)Po#}^Gyn))*)<_Q4W=6ah_BdMo^g+V^n>gaD)XC6kKgTb z7{&YC2vfH&0)n!HIPCR`NYB4)+F*lFDw}oKV`++n+B?anMb?U(o1l~_og4)x%m}G5 z6-BSmgC*Y!=Q7g?B>OQ<7i9iIL;c{-*q}C(Z5H|T0*)j#t7l@elK5#;z)9#_FSu3= z)}(9^%W??VMGOU(KI#t}Lc%6>`;8CA+~@!~+ZA^?oqtj)M)2>gtGp&ixyX~UD|aHy z)FBfqEPO6szQ)@3KK@_McAu;9_uCp>?c5N-LNKD){9o}y|(u9`AlKC*djM=Z|v z6}a^V)g$%v(5siSBms@ESHSpCJ<$gI0G_m0=icdIFTREjhN|w-X?~c`Iyy}XWU$4Pcn4FTR9sLEP*%e!PT#> z##Y>{;ui~(leaM@V>4v-n_)T3yj-)cjt+BTLY23+=D{ln=G%RU#B=O-bT+HdMHa#! z2H%04BSih2bhxga7_m_J4~)&C>-ht|4{aKN)#`&;*clq*Lgy`k3J5VH>iFp7P?=kn zE>V`or$k}+>l1Mp@nnCMf-vpPPfJTt7)`FFs_5d(XVuq^d7P?Xdt`{N%F)FX#l zVOQW2-tZfNcAJy`jw`2&7Rw$E4Vn-^BL?!)y@GpdmjuPBljg!2ekxHQs=f=yO05YZ z+1-tMc#fbVkbK75vyWGR;D$eH7shz%W6KOF z?CiUR%p&%=74^6lxdFw>dlq(TL|H(g-{j+y=;e|2I!1=(AQ=zcAlqquZ6J9GrG-^e zl9ZD?9`6ZvW8B>UYW{5O{D#x#z!Nu00u`^Uqa5Z`RjKhq$L7pJPGLGvWm5Ou1voWW zzfM9IBKE|~<eTvUZcALiJL!h2}VeP>7z@jaCo;y^8F(C6%9b%6SB>e zaJjJ3EyuidpnacKAKejHY-*=%Y=!z7ifn>?noZqSqM&eW7c@QdDRO5U7VGoAXfNDj zQ3gbM&EJ>S8W30JywOJe@#BtJAID5_W{$t0-qUSpilu&S!;>-y1Q9)tmzVqfKXL8^ zbhbh1GAH3jaxdL`G4iciZIEsZb9YFOs4px1ra4IW0P;N6Ev?M*tfSPMKxJ_uYg1LH z%kkWx=Ww7=$U2){K9@gV@fZmsLby2KI6RR1MHE%teDce%+qx2rijIZ!cX>Qh^OAB` zDK4=n(xs=8SuB=tjaIegTVXCwYu>uY{eh?RWX^|N$T{Ywzi)AZL&?FfBQ~lBK+cI( zpR^h)=ZWA>W$?31(v1%ZM0-H9HErn6G;g-FY->)+ob3cQrB@`W?mF&3VINiSThSdU zv)kpQnuf}wne;vM>kg~wawT6v58p8g!)AuFv0mdDj1%7WDX*SJAU>+In39KQtB&54 zK*v*W9;d+cjpmuCseR)YtT@a1tAOpYsOo+YQ#f89hcS}w1gpa=vSMOQ_um?p5ankc zMrab#0r>rhi&Qs)ir@=|4p5bZA5Zk$ez-dwIklL+a+9kQa*!_sc{R$a_%u`K-8 zVBCtNi?po_SA2PGYh^o)q4owG^oy-KM9yB)L{ATrzx(OuYy#5tDVjQ6xdXn7V<(5@ zWC3jy^hbzY4$Zn;!F zo00cRYi>byH|Zz<<=Stx{XTaF`@gu~9ckwfkR+~T{}=R1610hM5r!9(hK$qJ3sL~} zMmK{3mUXGIl#ww03%0^Qtxo@|7Dp~L?z$fRTJ&@mzbVY;fxQ^y-DG=h5hUQ=RUE1s z2=*3{JN{sAM)I-w_qL~seD3iI6Nu_HQ?+3x2(vR4`&W$2gO}PaQrsahGQ3C6Q00R` zptpy2?}9cUM{z$5`za literal 0 HcmV?d00001 diff --git a/packages/lexical-devtools/src/public/icon/16-restricted.png b/packages/lexical-devtools/src/public/icon/16-restricted.png new file mode 100644 index 0000000000000000000000000000000000000000..c97f6ffce254fb30328844e1e5c4cabbed3921c7 GIT binary patch literal 428 zcmV;d0aN~oP)n!fOD?#-yYqhF!aMG4S#6f%(nN%?wN+ zNswkj1|S1u^(@FXnksVFN&b_3L?t5U<~0wwWmpgQf--gN@c5oZ*4u1t%>& z1}Cdy41B!IVE*UNf5CLT=XD0=k3583FmK*GhL4|~Gn_j88H_;yMBljajbY82h4@_n zik9Wem*aG5QW7K#G&MEBYN06+VgQUoEC8v8>7h6&qXZ&80Mh^hpv(jVlx1fIh6@1Q Wse`|m1Tjkh0000cNK|GRU!~=wS?eQ`|#Q&8>RaXxht&KuLqP72j!Iyn1za zd6)e_@O<|>-}k%U{hf2Zvw`P%1As9ANAYiw0N~IV42D-NNKW7Z2vmVzEl4h+P3?ew zzrXIYcs!15HoLwAd_G^@XQ@k}X_6YCw+x5F z5Cnk{@_N0J*hHvIMyvoi#9}d32t*cCX`Dg2eVlVvuQKZlLbZeDut+sKp8##L2T`k3N!t5829h$aIja8 zdk^NJ*>nbWyPdtE*6L*bv&m6K#0U*FK*6@}6vQ9lj*Mif>1VzbLC6LSI(0ibS?%g_xo#Wcz9@tUiPZ~)jC)koyg+MeK#l`c+8autM zdqt2EVtNfsrbRYSDOT+V0(a)vwyP8~GeyO|1X%*j^aa*EsOfqd^%}AS(^evq?ljRA_^zazTlt4Qen(^NsAX-JkAm zcMB}?C7bQe&b+=i^Sw7SZ?=Mn$mjF` zisQIRBD#7wbTWlnkOw31$3aRfPQ>avc8UnmtFS`WbT`renPttm7I22JR91hdy=&1PI*VjksblOl`#RyDKPur`V z4zwIJ6bex=7*v$QI4Kkc*x^`a;C8!JYlBiobUE5i3`f#)BC8ObRgP>n%lrW0R%?`W z4S@1;&Xoh*-Q5bQsZ@$x@X2CS`hVtu6LoGn-K;(JZ*OMVH5P5A!F~_Dn%bc8$7=;K z9<;#aY1!-1Z)?^lYS;iMeZDG%U2Gq*{nIIkF*lgT6{5{Uvi?-SN2 zY72$J2oo5s(7UC`Zq0MH{OyOd;@l zbv~&mN6*()Z_`-xifX%&p+*V>GZcwLL?fhfAlC{^8j#Rb((S5ZzXj)g^w3AWJq^^< zSVM#T=ji#11+l)CoKj%e0M6^D_PeoyvZdD%;%YZ&Si=2${x`d?ukVOu1N|O)JNK2g zetM{L;Aw#j&~IN1sHu0;*^7JO@5|>j8x-iMRk zx@jyM13IC;)gG6_@CfXD&#_(yHa;!>o3viGDe850oMejPE(gMDAe3QF>}k0s%H0`gkDsM=cVRSa;^)TB#M4xm25>db6{R_CQ}lxrcQFiasV2e5O(9h@E>YE-R< z+4s`r5A3c6EH%(xD$fc`8Gs!U)-uDAuqlTSeWX!H3baF5g&fGUYJg!1VG_b*)YMcX zu6NXckO}^`s?bXS!Y;z9baE>dhDQK<1FUQA^qeVL4?PS&&>A5DkOO&EH%z9(=;Cso za=VjNsX%+ku>+>_LQBhe>bU;VmOr-xM|($x4RyKmM0$B4y##Pk1CR|Ucb@1tFC;H8 znv=8(dU+w!2JA;3NRDE8Aw3*ec3OHuqnBQH+zE}5qbC8!Cp4wP=o-ioCp1>zcnVeS vemLP&%sT#Kk4k*9f*%eW3Fz9+^F03-a-nRc%7Q3300000NkvXXu0mjfOFYz6 literal 0 HcmV?d00001 diff --git a/packages/lexical-devtools/src/store.ts b/packages/lexical-devtools/src/store.ts index 8b38b0a475b..3926e9830a6 100644 --- a/packages/lexical-devtools/src/store.ts +++ b/packages/lexical-devtools/src/store.ts @@ -17,11 +17,12 @@ import {SerializedRawEditorState} from './types'; export interface ExtensionState { lexicalState: { - [tabID: number]: {[editorKey: string]: SerializedRawEditorState}; + [tabID: number]: {[editorKey: string]: SerializedRawEditorState} | null; }; selectedEditorKey: { [tabID: number]: string | null; }; + markTabAsRestricted: (tabID: number) => void; setStatesForTab: ( id: number, states: {[editorKey: string]: SerializedRawEditorState}, @@ -32,6 +33,13 @@ export interface ExtensionState { export const useExtensionStore = create()( subscribeWithSelector((set) => ({ lexicalState: {}, + markTabAsRestricted: (tabID: number) => + set((state) => ({ + lexicalState: { + ...state.lexicalState, + [tabID]: null, + }, + })), selectedEditorKey: {}, setSelectedEditorKey: (tabID: number, editorKey: string | null) => set((state) => ({