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

Highlight DOM Nodes onHover of Lexical DevTools Node #2728

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
136 changes: 104 additions & 32 deletions packages/lexical-devtools/src/devtools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,46 +16,118 @@ 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() {
// Use devtools.inspectedWindow.eval() to get editorState updates. For more info on security concerns:
// 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
chrome.devtools.inspectedWindow
// 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
.eval(
`
const editor = document.querySelectorAll('div[data-lexical-editor]')[0]
.__lexicalEditor;

const initialEditorState = editor.getEditorState();

// custom JSON serialization
// existing editorState.toJSON() does not contain Lexical ._key property
const serializeEditorState = (editorState) => {
const nodeMap = Object.fromEntries(editorState._nodeMap);
return {nodeMap};
};


// 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;
};
`);
noi5e marked this conversation as resolved.
Show resolved Hide resolved

// 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');
Copy link
Contributor Author

@noi5e noi5e Jul 29, 2022

Choose a reason for hiding this comment

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

I realized a bit late that a lot of the stuff in devtools.js can be moved into content.js. Particularly the parts that don't depend on the editor object, but are just declaring functions and variables in the window scope.

This would be ideal since they would be linted as TS in content.ts, and they wouldn't be run in an eval.

I can either move it in this PR with another commit, or early in the next PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I realized a bit late that a lot of the stuff in devtools.js can be moved into content.js.

Actually forget about it, I spent the last couple of days working on this, but I can't get around the fact that content.js cannot document.querySelector the editorDOMNode. This is because content scripts have limited access to the DOM.

I don't think I'll be able to get around having the highlight/dehighlighting code within devtools.

@thegreatercurve This is otherwise ready to merge!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think I'll be able to get around having the highlight/dehighlighting code within devtools.

I've been experimenting with some of the approaches in this StackOverflow post.

Some of it seems promising. In particular, I think the way to go in the future will be to have the content script inject something via document.createElement('script'). React DevTools takes this approach while still making limited use of inspectedWindow.eval().

I tried to get this approach running yesterday, but for some reason it doesn't quite work yet. I think it has something to do with timing, like the script is being injected before the DOM finishes loading. I'll keep experimenting for now, but I think whatever changes I make should be the subject of a new PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

Try defer loading of the script by setting async to be true on the script, or add an event listener on the window for after the document has fully loaded.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Try defer loading of the script by setting async to be true on the script, or add an event listener on the window for after the document has fully loaded.

Nice, I tried some of these. I went with another workaround where the DevTools app sends a message, requesting to load the editorState the moment the DevTools panel is opened in the browser console. Seems to work fine.

The good news is that I got all of the code out of the eval() statement and into an injected script, and it works great. I'll bundle the changes into another PR right after this one is merged.

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(initialEditorState),
editorState: serializeEditorState(editorState),
name: 'editor-update',
type: 'FROM_PAGE',
},
'*',
);

editor.registerUpdateListener(({editorState}) => {
window.postMessage(
{
editorState: serializeEditorState(editorState),
name: 'editor-update',
type: 'FROM_PAGE',
},
'*',
);
});
`,
); // to do: add error handling eg. .then(handleError)
});
`);
}
33 changes: 31 additions & 2 deletions packages/lexical-devtools/src/panel/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import './index.css';

import {DevToolsTree} from 'packages/lexical-devtools/types';
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';

import TreeView from '../TreeView';

Expand All @@ -26,6 +26,30 @@ function App(): JSX.Element {
setNodeMap(newNodeMap);
};

// highlight & dehighlight the corresponding DOM nodes onHover of DevTools nodes
const highlightDOMNode = useCallback(
(lexicalKey: string) => {
port.current?.postMessage({
lexicalKey,
name: 'highlight',
tabId: window.chrome.devtools.inspectedWindow.tabId,
type: 'FROM_APP',
});
},
[port],
);

const deHighlightDOMNode = useCallback(
(lexicalKey: string) => {
port.current?.postMessage({
name: 'dehighlight',
tabId: window.chrome.devtools.inspectedWindow.tabId,
type: 'FROM_APP',
});
},
[port],
);

useEffect(() => {
// create and initialize the messaging port to receive editorState updates
port.current = window.chrome.runtime.connect();
Expand Down Expand Up @@ -64,7 +88,12 @@ function App(): JSX.Element {
<p>Loading...</p>
</div>
) : (
<TreeView viewClassName="tree-view-output" nodeMap={nodeMap} />
<TreeView
deHighlightDOMNode={deHighlightDOMNode}
highlightDOMNode={highlightDOMNode}
viewClassName="tree-view-output"
nodeMap={nodeMap}
/>
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
padding: 0;
text-decoration: none;
}

.tree-node:hover > .chevron-button {
background: #77b6ff;
color: #222;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@
user-select: none;
}

.tree-node {
padding-left: 1em;
width: 100%;
.tree-node:hover {
background: #77b6ff;
color: #222;
}

.tree-node-wrapper {
cursor: pointer;
white-space: nowrap;
width: 100%;
}

.tree-node-wrapper:first-child {
width: 100vw;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,26 @@ function TreeNode({
__text,
__type,
children,
deHighlightDOMNode,
depth,
highlightDOMNode,
lexicalKey,
monospaceWidth,
}: DevToolsNode): JSX.Element {
const [isExpanded, setIsExpanded] = useState(true);

const handleChevronClick = () => {
setIsExpanded(!isExpanded);
};

const handleMouseEnter: React.MouseEventHandler = (event) => {
highlightDOMNode(lexicalKey);
};

const handleMouseLeave: React.MouseEventHandler = (event) => {
deHighlightDOMNode(lexicalKey);
};

const nodeString = ` (${lexicalKey}) ${__type} ${
__text ? '"' + __text + '"' : ''
}`;
Expand All @@ -40,15 +51,25 @@ function TreeNode({
''
);

const leftIndent = depth * parseFloat(monospaceWidth) + 'em';

return (
<div className="tree-node" key={lexicalKey}>
{children.length > 0 ? (
<Chevron handleClick={handleChevronClick} isExpanded={isExpanded} />
) : (
<button className="indentation">&#9654;</button>
)}
{nodeString}
{<br />}
<div className="tree-node-wrapper" key={lexicalKey}>
<div
className="tree-node"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{paddingLeft: leftIndent}}>
<span style={{width: 'var(--monospace-character-width)'}}>&nbsp;</span>
{children.length > 0 ? (
<Chevron handleClick={handleChevronClick} isExpanded={isExpanded} />
) : (
<span style={{width: 'var(--monospace-character-width)'}}>
&nbsp;
</span>
)}
{nodeString}
</div>
{isExpanded ? childNodes : ''}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
background: #222;
color: #fff;
margin: 0;
padding: 1em;
padding: 1em 0;
font-size: 1.2em;
overflow: auto;
text-align: left;
-moz-osx-font-smoothing: grayscale;
font-weight: 400;
--indentation-size: 1em;
width: max-content;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,21 @@ import * as React from 'react';
import TreeNode from '../TreeNode';

function TreeView({
deHighlightDOMNode,
highlightDOMNode,
viewClassName,
nodeMap,
}: {
deHighlightDOMNode: (lexicalKey: string) => void;
highlightDOMNode: (lexicalKey: string) => void;
viewClassName: string;
nodeMap: DevToolsTree;
}): JSX.Element {
// read CSS variable from the DOM in order to pass it to TreeNode
const monospaceWidth = getComputedStyle(
document.documentElement,
).getPropertyValue('--monospace-character-width');

// takes flat JSON structure, nests child comments inside parents
const depthFirstSearch = (
map: DevToolsTree = nodeMap,
Expand All @@ -38,8 +47,11 @@ function TreeView({
...node,
__type: node.__type,
children,
deHighlightDOMNode,
depth,
highlightDOMNode,
lexicalKey: node.__key,
monospaceWidth,
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
:root {
--monospace-character-width: 1.2em;
}

body {
margin: 0;
font-family: monospace;
Expand Down
3 changes: 3 additions & 0 deletions packages/lexical-devtools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface DevToolsNode {
__text?: string;
__type: string;
children: Array<DevToolsNode>;
deHighlightDOMNode: (lexicalKey: string) => void;
depth: number;
highlightDOMNode: (lexicalKey: string) => void;
lexicalKey: string;
monospaceWidth: string;
}