Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inject DevTools Script in Browser Extension #2778

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/lexical-devtools/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
"js": ["src/content/index.js"]
}
],
"permissions": ["activeTab"]
"permissions": ["activeTab"],
"web_accessible_resources": ["src/inject/index.js"]
}
23 changes: 22 additions & 1 deletion packages/lexical-devtools/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,27 @@ chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => {
}

if (message.name === 'init' && message.type === 'FROM_APP') {
tabsToPorts[tabId] = tabsToPorts[tabId] ? tabsToPorts[tabId] : {};
tabsToPorts[message.tabId].reactPort = port;
return;
}

if (message.name === 'init' && message.type === 'FROM_CONTENT') {
tabsToPorts[tabId] = {};
tabsToPorts[tabId] = tabsToPorts[tabId] ? tabsToPorts[tabId] : {};
tabsToPorts[tabId].contentScriptPort = port;
return;
}

// initial editorState requested from devtools panel
if (message.name === 'init' && message.type === 'FROM_DEVTOOLS') {
const contentScriptPort = tabsToPorts[tabId].contentScriptPort;
if (contentScriptPort) {
contentScriptPort.postMessage({
name: 'loadEditorState',
});
}
}

if (message.name === 'editor-update') {
const reactPort = tabsToPorts[tabId].reactPort;
if (reactPort) {
Expand All @@ -47,5 +58,15 @@ chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => {
});
}
}

if (message.name === 'highlight' || message.name === 'dehighlight') {
const contentScriptPort = tabsToPorts[tabId].contentScriptPort;
if (contentScriptPort) {
contentScriptPort.postMessage({
lexicalKey: message.lexicalKey ? message.lexicalKey : null,
name: message.name,
});
}
}
});
});
60 changes: 60 additions & 0 deletions packages/lexical-devtools/src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@
* LICENSE file in the root directory of this source tree.
*
*/
import type {CloneInto} from '../../types';

import {IS_FIREFOX} from 'shared/environment';

declare global {
interface DocumentEventMap {
editorStateUpdate: CustomEvent;
highlight: CustomEvent;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the highlight CustomEvent. I'm unsure if a declare global is what I'm supposed to be doing here. @thegreatercurve

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do, it will just get referenced in this module scope either way.


// for security reasons, content scripts cannot read Lexical's changes to the DOM
// in order to access the editorState, we inject this script directly into the page
const script = document.createElement('script');
script.src = chrome.runtime.getURL('src/inject/index.js');
document.documentElement.appendChild(script);
if (script.parentNode) script.parentNode.removeChild(script);

const port = chrome.runtime.connect();

port.postMessage({
Expand All @@ -31,3 +49,45 @@ window.addEventListener('message', function (event) {
});
}
});

document.addEventListener('editorStateUpdate', function (e) {
port.postMessage({
editorState: e.detail.editorState,
name: 'editor-update',
type: 'FROM_CONTENT',
});
});

function getCloneInto(): CloneInto | null {
// @ts-ignore
if (typeof globalThis.cloneInto === 'function') {
// @ts-ignore
return globalThis.cloneInto;
}
return null;
}

const cloneInto = getCloneInto();

port.onMessage.addListener((message) => {
if (message.name === 'highlight') {
const data = {lexicalKey: message.lexicalKey as string};
const detail =
IS_FIREFOX && cloneInto && document && document.defaultView
? cloneInto(data, document.defaultView)
: data;
document.dispatchEvent(
new CustomEvent('highlight', {
detail,
}),
);
}

if (message.name === 'dehighlight') {
document.dispatchEvent(new CustomEvent('dehighlight'));
}

if (message.name === 'loadEditorState') {
document.dispatchEvent(new CustomEvent('loadEditorState'));
}
});
122 changes: 9 additions & 113 deletions packages/lexical-devtools/src/devtools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree.
*
*/
const port = chrome.runtime.connect();

// Create the panel which appears within the browser's DevTools, loading the Lexical DevTools App within index.html.
chrome.devtools.panels.create(
'Lexical',
Expand All @@ -16,118 +18,12 @@ chrome.devtools.panels.create(
},
);

chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((message) => {
if (message.name === 'highlight') {
chrome.devtools.inspectedWindow.eval(`
highlight('${message.lexicalKey}');
`);
}

if (message.name === 'dehighlight') {
chrome.devtools.inspectedWindow.eval(`
dehighlight();
`);
}
});
});

function handleShown() {
// Security concerns related to chrome.devtools.inspectedWindow.eval():
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/devtools/inspectedWindow/eval

// query document for Lexical editor instance.
// TODO: add support multiple Lexical editors within the same page
// lexicalKey is attached to each DOM node like so: DOMNode[__lexicalKey_{editorKey}]
chrome.devtools.inspectedWindow.eval(`
const editorDOMNode = document.querySelectorAll(
'div[data-lexical-editor]',
)[0];
const editor = editorDOMNode.__lexicalEditor;
const editorKey = editorDOMNode.__lexicalEditor._key;
const lexicalKey = '__lexicalKey_' + editorKey;
`);

// depth first search to find DOM node with lexicalKey
chrome.devtools.inspectedWindow.eval(`
const findDOMNode = (node, targetKey) => {
if (targetKey === 'root') {
return editorDOMNode;
}

if (node[lexicalKey] && node[lexicalKey] === targetKey) {
return node;
}

for (let i = 0; i < node.childNodes.length; i++) {
const child = findDOMNode(node.childNodes[i], targetKey);
if (child) return child;
}

return null;
};
`);

// functions to highlight/dehighlight DOM nodes onHover of DevTools nodes
chrome.devtools.inspectedWindow.eval(`
const highlight = (lexicalKey) => {
const node = findDOMNode(editorDOMNode, lexicalKey);
const {width, height, top, left} = node.getBoundingClientRect();
highlightOverlay.style.width = width + 'px';
highlightOverlay.style.height = height + 'px';
highlightOverlay.style.top = top + window.scrollY + 'px';
highlightOverlay.style.left = left + window.scrollX + 'px';
highlightOverlay.style.display = 'block';
};

const dehighlight = () => {
highlightOverlay.style.display = 'none';
};
`);

// append highlight overlay <div> to document
chrome.devtools.inspectedWindow.eval(`
const highlightOverlay = document.createElement('div');
highlightOverlay.style.position = 'absolute';
highlightOverlay.style.background = 'rgba(119, 182, 255, 0.5)';
highlightOverlay.style.border = '1px dashed #77b6ff';
document.body.appendChild(highlightOverlay);
`);

// send initial editorState to devtools app through window.postMessage.
// the existing editorState.toJSON() does not contain lexicalKey
// therefore, we have a custom serializeEditorState helper
chrome.devtools.inspectedWindow.eval(`
const initialEditorState = editor.getEditorState();

const serializeEditorState = (editorState) => {
const nodeMap = Object.fromEntries(editorState._nodeMap);
return {nodeMap};
};

window.postMessage(
{
editorState: serializeEditorState(initialEditorState),
name: 'editor-update',
type: 'FROM_PAGE',
},
'*',
);
`);

// Attach a registerUpdateListener within the window to subscribe to changes in editorState.
// After the initial registration, all editorState updates are done via browser.runtime.onConnect & window.postMessage
chrome.devtools.inspectedWindow.eval(`
editor.registerUpdateListener(({editorState}) => {
window.postMessage(
{
editorState: serializeEditorState(editorState),
name: 'editor-update',
type: 'FROM_PAGE',
},
'*',
);
});
`);
// init message goes → background script → content script
// content script handles initial load of editorState
port.postMessage({
name: 'init',
tabId: chrome.devtools.inspectedWindow.tabId,
type: 'FROM_DEVTOOLS',
});
}
100 changes: 100 additions & 0 deletions packages/lexical-devtools/src/inject/index.ts
Original file line number Diff line number Diff line change
@@ -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 {EditorState} from 'lexical';
import {LexicalHTMLElement, LexicalKey} from 'packages/lexical-devtools/types';

let editorDOMNode: LexicalHTMLElement | null, editorKey: string | null;

// the existing editorState.toJSON() does not contain lexicalKeys
// therefore, we have a custom serializeEditorState helper
const serializeEditorState = (editorState: EditorState) => {
const nodeMap = Object.fromEntries(editorState._nodeMap); // convert from Map structure to JSON-friendly object
return {nodeMap};
};

const postEditorState = (editorState: EditorState) => {
const serializedEditorState = serializeEditorState(editorState);
const data = {editorState: serializedEditorState};
document.dispatchEvent(new CustomEvent('editorStateUpdate', {detail: data}));
};

document.addEventListener('loadEditorState', function (e) {
// query document for Lexical editor instance.
// TODO: add support multiple Lexical editors within the same page
editorDOMNode = document.querySelectorAll(
'div[data-lexical-editor]',
)[0] as LexicalHTMLElement;
const editor = editorDOMNode.__lexicalEditor;
editorKey = editorDOMNode.__lexicalEditor._key; // dynamically generated upon each pageload, we need this to find DOM nodes for highlighting

const initialEditorState = editor.getEditorState();
postEditorState(initialEditorState);

editor.registerUpdateListener(({editorState}) => {
postEditorState(editorState);
});
});

// append highlight overlay <div> to document
const highlightOverlay = document.createElement('div');
highlightOverlay.style.position = 'absolute';
highlightOverlay.style.background = 'rgba(119, 182, 255, 0.5)';
highlightOverlay.style.border = '1px dashed #77b6ff';
document.body.appendChild(highlightOverlay);

// functions to highlight/dehighlight DOM nodes onHover of DevTools nodes
document.addEventListener('highlight', (evt: CustomEvent) => {
highlight(evt.detail.lexicalKey);
});

document.addEventListener('dehighlight', function () {
dehighlight();
});

// depth first search to find DOM node with lexicalKey
const findDOMNode = (
node: LexicalHTMLElement,
targetKey: string,
): LexicalHTMLElement | null => {
// each DOM node has its Lexical key stored at a particular key eg. DOMNode[__lexicalKey_pzely]
const key = ('__lexicalKey_' + editorKey) as LexicalKey;

if (targetKey === 'root') {
return editorDOMNode;
}

if (node[key] && node[key] === targetKey) {
return node;
}

for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i] as LexicalHTMLElement;
const childResults = findDOMNode(child, targetKey);
if (childResults) return childResults;
}

return null;
};

const highlight = (targetKey: string) => {
if (editorDOMNode) {
const node = findDOMNode(editorDOMNode, targetKey);
if (node) {
const {width, height, top, left} = node.getBoundingClientRect();
highlightOverlay.style.width = width + 'px';
highlightOverlay.style.height = height + 'px';
highlightOverlay.style.top = top + window.scrollY + 'px';
highlightOverlay.style.left = left + window.scrollX + 'px';
highlightOverlay.style.display = 'block';
}
}
};

const dehighlight = () => {
highlightOverlay.style.display = 'none';
};
14 changes: 14 additions & 0 deletions packages/lexical-devtools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree.
*
*/
import {LexicalEditor} from 'lexical';

export interface DevToolsTree {
[key: string]: DevToolsNode;
}
Expand All @@ -21,3 +23,15 @@ export interface DevToolsNode {
lexicalKey: string;
monospaceWidth: string;
}

export type LexicalKey = `__lexicalKey_${string}`;

export interface LexicalHTMLElement extends HTMLElement {
[key: LexicalKey]: string;
__lexicalEditor: LexicalEditor;
}

export type CloneInto = (
arg: {lexicalKey: string},
arg2: Window,
) => {data: {lexicalKey: string}};
Loading