Skip to content

Commit

Permalink
Inject DevTools Script in Browser Extension (#2778)
Browse files Browse the repository at this point in the history
* move devtools code to injected script

* correct typescript errors

* write cloneInto type

* add injected script to web_accessible_resources

* fix highlighting bug

* wrapper function for cloneInto
  • Loading branch information
noi5e authored and thegreatercurve committed Nov 25, 2022
1 parent c3a01fb commit 3cf31ee
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 115 deletions.
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;
}
}

// 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

0 comments on commit 3cf31ee

Please sign in to comment.