Working ClickToComponent

This commit is contained in:
Eric Clemmons
2022-04-21 17:38:48 -05:00
parent 1dc79757fa
commit c9ddb9732f
23 changed files with 3959 additions and 5568 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"js/ts.implicitProjectConfig.checkJs": true
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "next",
"private": true,
"name": "next",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -8,16 +8,16 @@
"lint": "next lint"
},
"dependencies": {
"click-to-component": "link:../../packages/click-to-component",
"next": "12.1.0",
"react": "17.0.2",
"react-dom": "17.0.2"
"next": "12.1.5",
"react": "latest",
"react-dom": "18.0.0"
},
"devDependencies": {
"@types/node": "17.0.21",
"@types/react": "17.0.40",
"eslint": "8.11.0",
"eslint-config-next": "12.1.0",
"typescript": "4.6.2"
"@types/node": "17.0.23",
"@types/react": "18.0.3",
"click-to-react-component": "workspace:^",
"eslint": "8.13.0",
"eslint-config-next": "12.1.5",
"typescript": "4.6.3"
}
}

View File

@@ -1,11 +1,14 @@
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { useClickToComponent } from 'click-to-component'
import { ClickToComponent } from 'click-to-react-component'
function MyApp({ Component, pageProps }: AppProps) {
useClickToComponent()
return <Component {...pageProps} />
return (
<>
<ClickToComponent editor="vscode-insiders" />
<Component {...pageProps} />
</>
)
}
export default MyApp

View File

@@ -3,11 +3,16 @@
"main": "index.js",
"scripts": {
"build": "turbo run build",
"env": "env",
"dev": "turbo run dev --parallel",
"lint": "turbo run lint"
},
"devDependencies": {
"turbo": "^1.1.6"
"eslint": "8.13.0",
"turbo": "^1.2.2"
},
"packageManager": "pnpm@6.32.3"
"packageManager": "pnpm@6.32.3",
"dependencies": {
"unbuild": "^0.7.3"
}
}

View File

@@ -1,34 +0,0 @@
{
"name": "click-to-component",
"version": "1.0.0",
"description": "Option+Click your React/Vue components in your browser to open the source file in VS Code",
"source": "src/index.ts",
"main": "dist/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ericclemmons/click-to-component.git"
},
"keywords": [
"react",
"vue",
"dx",
"vscode",
"devtools"
],
"author": "Eric Clemmons <eric@smarterspam.com>",
"license": "ISC",
"bugs": {
"url": "https://github.com/ericclemmons/click-to-component/issues"
},
"homepage": "https://github.com/ericclemmons/click-to-component#readme",
"devDependencies": {
"tsup": "^5.12.1"
}
}

View File

@@ -1,3 +0,0 @@
export function useClickToComponent() {
console.log("Howdy");
}

View File

@@ -1,19 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig((options) => ({
clean: !options.watch,
// https://tsup.egoist.sh/#generate-declaration-file
dts: true,
// https://tsup.egoist.sh/#typescript--javascript
entry: ["src/**/*.ts"],
// https://tsup.egoist.sh/#bundle-formats
format: ["cjs", "esm"],
// https://tsup.egoist.sh/#metafile
metafile: !!options.watch,
// https://tsup.egoist.sh/#minify-output
minify: !options.watch,
// https://tsup.egoist.sh/#bundle-formats
legacyOutput: true,
// https://tsup.egoist.sh/#generate-sourcemap-file
sourcemap: "inline",
}));

View File

@@ -0,0 +1,3 @@
{
"extends": "react-app"
}

View File

@@ -0,0 +1 @@
export { ClickToComponent } from './src/ClickToComponent'

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"checkJs": true,
"noImplicitThis": true
},
"exclude": ["node_modules", "**/node_modules/*"]
}

View File

@@ -0,0 +1,45 @@
{
"name": "click-to-react-component",
"version": "1.0.0",
"description": "Option+Click your React components in your browser to open the source file in VS Code",
"types": "types.d.ts",
"scripts": {
"build": "echo \"Fuck build tools\"",
"dev": "pnpm run build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ericclemmons/click-to-component.git"
},
"keywords": [
"react",
"vue",
"dx",
"vscode",
"devtools"
],
"author": "Eric Clemmons <eric@smarterspam.com>",
"license": "ISC",
"bugs": {
"url": "https://github.com/ericclemmons/click-to-component/issues"
},
"homepage": "https://github.com/ericclemmons/click-to-component#readme",
"peerDependencies": {
"react": ">=16.8.0"
},
"dependencies": {
"@popperjs/core": "^2.11.5",
"@radix-ui/colors": "^0.1.8",
"@radix-ui/react-dropdown-menu": "^0.1.6",
"@radix-ui/react-icons": "^1.1.0",
"@stitches/react": "^1.2.7",
"htm": "^3.1.0",
"react-popper": "^2.2.5"
},
"devDependencies": {
"@types/react": "^18.0.5",
"eslint": "^8.0.0",
"eslint-config-react-app": "^7.0.1"
}
}

View File

@@ -0,0 +1,199 @@
/**
* @typedef {import('../types').ClickToComponentProps} Props
*/
import { html } from 'htm/react'
import * as React from 'react'
import { ContextMenu } from './ContextMenu'
import { getSourceForElement } from './getSourceForElement'
import { getPathToSource } from './getPathToSource'
export const State = /** @type {const} */ ({
IDLE: 'IDLE',
HOVER: 'HOVER',
SELECT: 'SELECT',
})
/**
* @param {Props} props
*/
export function ClickToComponent({ editor }) {
const [state, setState] = React.useState(
/** @type {State[keyof State]} */
(State.IDLE)
)
const [target, setTarget] = React.useState(
/** @type {HTMLElement | null} */
(null)
)
const onClick = React.useCallback(
function handleClick(
/**
* @type {MouseEvent}
*/
event
) {
if (state === State.HOVER && target instanceof HTMLElement) {
const source = getSourceForElement(target)
const path = getPathToSource(source)
const url = `${editor}://file/${path}`
event.preventDefault()
window.open(url)
setState(State.IDLE)
}
},
[editor, state, target]
)
const onClose = React.useCallback(
function handleClose(event, returnValue) {
if (returnValue) {
const url = `${editor}://file/${returnValue}`
window.open(url)
}
setState(State.IDLE)
},
[editor]
)
const onContextMenu = React.useCallback(
function handleContextMenu(
/**
* @type {MouseEvent}
*/
event
) {
if (state === State.HOVER && target instanceof HTMLElement) {
event.preventDefault()
setState(State.SELECT)
}
},
[state, target]
)
const onKeyDown = React.useCallback(
function handleKeyDown(
/**
* @type {KeyboardEvent}
*/
event
) {
switch (state) {
case State.IDLE:
if (event.altKey) setState(State.HOVER)
break
default:
}
},
[state]
)
const onKeyUp = React.useCallback(
function handleKeyUp(
/**
* @type {KeyboardEvent}
*/
event
) {
switch (state) {
case State.HOVER:
setState(State.IDLE)
break
default:
}
},
[state]
)
const onMouseMove = React.useCallback(
function handleMouseMove(
/** @type {MouseEvent} */
event
) {
if (!(event.target instanceof HTMLElement)) {
return
}
switch (state) {
case State.IDLE:
case State.HOVER:
setTarget(event.target)
break
default:
break
}
},
[state]
)
React.useEffect(
function toggleIndicator() {
for (const element of Array.from(
document.querySelectorAll('[data-click-to-component]')
)) {
if (element instanceof HTMLElement) {
delete element.dataset.clickToComponent
}
}
if (state === State.IDLE) {
return
}
if (target instanceof HTMLElement) {
target.dataset.clickToComponent = state
}
},
[state, target]
)
React.useEffect(
function addEventListenersToWindow() {
window.addEventListener('click', onClick, { capture: true })
window.addEventListener('contextmenu', onContextMenu, { capture: true })
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('mousemove', onMouseMove)
return function removeEventListenersFromWindow() {
window.removeEventListener('click', onClick, { capture: true })
window.removeEventListener('contextmenu', onContextMenu, {
capture: true,
})
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('mousemove', onMouseMove)
}
},
[onClick, onContextMenu, onKeyDown, onKeyUp, onMouseMove]
)
return html`
<style key="click-to-component-style">
[data-click-to-component] {
cursor: var(--click-to-component-cursor, context-menu) !important;
outline: var(
--click-to-component-outline,
2px solid lightgreen
) !important;
}
</style>
${state === State.SELECT
? html`<${ContextMenu}
key="click-to-component-contextmenu"
onClose=${onClose}
target=${target}
/>`
: null}
`
}

View File

@@ -0,0 +1,283 @@
/**
* @typedef {import('react').DialogHTMLAttributes} DialogHTMLAttributes
* @typedef {import('react').HTMLAttributes} HTMLAttributes
* @typedef {import('react').MouseEvent<HTMLElement, MouseEvent>} ReactMouseEvent}
*/
import { html } from 'htm/react'
import * as React from 'react'
import { usePopper } from 'react-popper'
import { getDisplayNameForInstance } from './getDisplayNameFromReactInstance'
import { getPathToSource } from './getPathToSource'
import { getReactInstancesForElement } from './getReactInstancesForElement'
import { getSourceForInstance } from './getSourceForInstance'
import { getPropsForInstance } from './getPropsForInstance'
export function ContextMenu({ onClose, target }) {
const [contextMenuElement, setContextMenuElement] = React.useState(
/** {HTMLElement | null} */
null
)
const [arrowElement, setArrowElement] = React.useState(
/** {HTMLElement | null} */
null
)
const { styles, attributes } = usePopper(target, contextMenuElement, {
modifiers: [
{
name: 'arrow',
options: { element: arrowElement },
},
{
name: 'offset',
options: { offset: [0, 8] },
},
],
})
React.useEffect(
// Careful this can be buggy in strict mode: https://github.com/facebook/react/issues/24399#issuecomment-1104191934
function toggleContextMenu() {
if (contextMenuElement) {
contextMenuElement.showModal()
return () => contextMenuElement.close()
}
},
[contextMenuElement]
)
// Workaround for this bullshit  https://github.com/microsoft/typescript-styled-plugin/issues/100
const popperProps = /** @type {DialogHTMLAttributes} */ ({
// Prevent focusing of elements outside of <dialog>
inert: '',
// When using `.showModal()`, the backdrop `event.target` is `<dialog>`
onClick(
/**
* @type {ReactMouseEvent}
*/
event
) {
if (
event.target instanceof HTMLElement &&
event.target.nodeName === 'DIALOG'
) {
contextMenuElement.close()
}
},
onClose(event) {
onClose?.(event, contextMenuElement.returnValue)
},
ref: setContextMenuElement,
style: styles.popper,
...attributes.popper,
})
const arrowProps = /** @type {HTMLAttributes} */ ({
ref: setArrowElement,
style: styles.arrow,
})
const instances = getReactInstancesForElement(target).filter((instance) =>
getSourceForInstance(instance)
)
return html`
<style key="click-to-component-dialog-style">
#click-to-component-dialog::backdrop {
background: transparent;
transition: backdrop-filter 0.5s ease;
}
#click-to-component-dialog,
#click-to-component-dialog * {
box-sizing: border-box !important;
}
#click-to-component-dialog {
background: white;
color: black;
font-weight: bold;
overflow: visible;
padding: 4px 8px;
font-size: 13px;
border-radius: 6px;
border: none;
--shadow-color: 0deg 0% 0%;
--shadow-elevation-low: 0px -1px 0.8px hsl(var(--shadow-color) / 0.1),
0px -1.2px 0.9px -2.5px hsl(var(--shadow-color) / 0.07),
0px -3px 2.3px -5px hsl(var(--shadow-color) / 0.03);
--shadow-elevation-medium: 0px 1px 0.8px hsl(var(--shadow-color) / 0.11),
0px 1.5px 1.1px -1.7px hsl(var(--shadow-color) / 0.08),
0px 5.1px 3.8px -3.3px hsl(var(--shadow-color) / 0.05),
0px 15px 11.3px -5px hsl(var(--shadow-color) / 0.03);
--shadow-elevation-high: 0px 1px 0.8px hsl(var(--shadow-color) / 0.1),
0px 1.1px 0.8px -0.7px hsl(var(--shadow-color) / 0.09),
0px 2.1px 1.6px -1.4px hsl(var(--shadow-color) / 0.07),
0px 4.9px 3.7px -2.1px hsl(var(--shadow-color) / 0.06),
0px 10.1px 7.6px -2.9px hsl(var(--shadow-color) / 0.05),
0px 18.9px 14.2px -3.6px hsl(var(--shadow-color) / 0.04),
0px 31.9px 23.9px -4.3px hsl(var(--shadow-color) / 0.02),
0px 50px 37.5px -5px hsl(var(--shadow-color) / 0.01);
box-shadow: var(--shadow-elevation-high);
filter: drop-shadow(0px 0px 0.5px rgba(0 0 0 / 50%));
}
#click-to-component-dialog ol {
list-style: none;
margin: 0;
padding: 5px 0;
display: flex;
flex-direction: column;
}
#click-to-component-dialog ol > li {
border-radius: 3px;
}
#click-to-component-dialog ol > * + * {
margin-top: 15px;
}
#click-to-component-dialog button {
all: unset;
display: flex;
flex-direction: column;
width: 100%;
background: white;
padding: 5px;
border-radius: 3px;
}
#click-to-component-dialog button:hover {
cursor: pointer;
filter: invert(1) hue-rotate(180deg) contrast(0.9);
}
#click-to-component-dialog button > * + * {
margin-top: 5px;
}
#click-to-component-dialog button code {
color: rgb(87, 70, 175);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
#click-to-component-dialog button code var {
background: rgba(0 0 0 / 5%);
cursor: help;
border-radius: 3px;
padding: 3px 6px;
font-style: normal;
font-weight: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
#click-to-component-dialog button cite {
font-weight: normal;
font-style: normal;
opacity: 0.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
#click-to-component-dialog button cite data::after {
content: attr(value);
float: right;
padding-left: 15px;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
#click-to-component-dialog-arrow,
#click-to-component-dialog-arrow::before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
}
#click-to-component-dialog-arrow {
visibility: hidden;
}
#click-to-component-dialog-arrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
#click-to-component-dialog[data-popper-placement^='top']
> #click-to-component-dialog-arrow {
bottom: -4px;
}
#click-to-component-dialog[data-popper-placement^='bottom']
> #click-to-component-dialog-arrow {
top: -4px;
}
#click-to-component-dialog[data-popper-placement^='left']
> #click-to-component-dialog-arrow {
right: -4px;
}
#click-to-component-dialog[data-popper-placement^='right']
> #click-to-component-dialog-arrow {
left: -4px;
}
</style>
<dialog
id="click-to-component-dialog"
key="click-to-component-dialog"
...${popperProps}
>
<form method="dialog">
<ol>
${instances.map((instance, i) => {
const name = getDisplayNameForInstance(instance)
const source = getSourceForInstance(instance)
const path = getPathToSource(source)
const props = getPropsForInstance(instance)
return html`
<li key=${i}>
<button value=${path}>
<code>
${'<'}${name}
${Object.entries(props).map(
([prop, value]) => html`
${' '}
<var key=${prop} title="${value}">${prop}</var>
`
)}
${'>'}
</code>
<cite>
<data value="${source.lineNumber}:${source.columnNumber}">
${source.fileName}
</data>
</cite>
</button>
</li>
`
})}
</ol>
</form>
<div id="click-to-component-dialog-arrow" ...${arrowProps} />
</dialog>
`
}

View File

@@ -0,0 +1,44 @@
/**
* @typedef {import('react-reconciler').Fiber} Fiber
* @param {Fiber} instance
*/
export function getDisplayNameForInstance(instance) {
const { elementType, tag } = instance
switch (tag) {
case 0: // FunctionComponent
case 1: // ClassComponent
return (
elementType.displayName || elementType.name || 'Anonymous Component'
)
case 5: // HostComponent:
return elementType
case 6: // HostText:
return 'String'
case 7: // Fragment
return 'React.Fragment'
case 9: // ContextConsumer
return 'Context.Consumer'
case 10: // ContextProvider
return 'Context.Provider'
case 11: // ForwardRef
return 'React.forwardRef'
case 15: // MemoComponent
// Attempt to get name from wrapped component
return elementType.type.name || 'React.memo'
case 16: // LazyComponent
return 'React.lazy'
default:
console.warn(`Unrecognized React Fiber tag: ${tag}`, instance)
return 'Unknown Component'
}
}

View File

@@ -0,0 +1,18 @@
/**
* @typedef {import('react-reconciler').Source} Source
*/
/**
* @param {Source} source
*/
export function getPathToSource(source) {
const {
// It _does_ exist!
// @ts-ignore Property 'columnNumber' does not exist on type 'Source'.ts(2339)
columnNumber = 1,
fileName,
lineNumber = 1,
} = source
return `${fileName}:${lineNumber}:${columnNumber}`
}

View File

@@ -0,0 +1,38 @@
/**
* @typedef {import('react-reconciler').Fiber} Fiber
*/
/**
* @param {Fiber} instance
*/
export function getPropsForInstance(instance) {
/**
* @type {Fiber['pendingProps']}
*/
const props = {}
console.log({ instance })
Object.entries(instance.memoizedProps).forEach(([key, value]) => {
const type = typeof value
// Ignore some values, even if they're scalar, because they're not unique enough
if (['key'].includes(key) || value === instance.type.defaultProps?.[key]) {
return
}
// Scalar values
if (
['string', 'number', 'boolean', 'symbol'].includes(type) ||
value instanceof String ||
value instanceof Number ||
value instanceof Boolean ||
value instanceof Symbol
) {
props[key] = value
}
})
return props
}

View File

@@ -0,0 +1,41 @@
/**
* @param {HTMLElement} element
*/
export function getReactInstanceForElement(element) {
// Prefer React DevTools, which has direct access to `react-dom` for mapping `element` <=> Fiber
if ('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window) {
// @ts-expect-error - TS2339 - Property '__REACT_DEVTOOLS_GLOBAL_HOOK__' does not exist on type 'Window & typeof globalThis'.
const { renderers } = window.__REACT_DEVTOOLS_GLOBAL_HOOK__
for (const renderer of renderers.values()) {
try {
const fiber = renderer.findFiberByHostInstance(element)
if (fiber) {
return fiber
}
} catch (e) {
// If React is mid-render, references to previous nodes may disappear during the click events
// (This is especially true for interactive elements, like menus)
}
}
}
if ('_reactRootContainer' in element) {
// @ts-expect-error - TS2339 - Property '_reactRootContainer' does not exist on type 'HTMLElement'.
return element._reactRootContainer._internalRoot.current.child
}
// eslint-disable-next-line guard-for-in
for (const key in element) {
// Pre-Fiber access React internals
if (key.startsWith('__reactInternalInstance$')) {
return element[key]
}
// Fiber access to React internals
if (key.startsWith('__reactFiber')) {
return element[key]
}
}
}

View File

@@ -0,0 +1,22 @@
/**
* @typedef {import('react-reconciler').Fiber} Fiber
*/
import { getReactInstanceForElement } from './getReactInstanceForElement'
export function getReactInstancesForElement(
/** @type {HTMLElement} */
element
) {
/** @type {Set<Fiber>} */
const instances = new Set()
let instance = getReactInstanceForElement(element)
while (instance) {
instances.add(instance)
instance = instance._debugOwner
}
return Array.from(instances)
}

View File

@@ -0,0 +1,20 @@
import { getReactInstanceForElement } from './getReactInstanceForElement'
import { getSourceForInstance } from './getSourceForInstance'
/**
* @typedef {import('react-reconciler').Fiber} Fiber
*/
export function getSourceForElement(
/**
* @type {HTMLElement}
*/
element
) {
const instance = getReactInstanceForElement(element)
const source = getSourceForInstance(instance)
if (source) return source
console.warn("Couldn't find a React instance for the element", element)
}

View File

@@ -0,0 +1,21 @@
/**
* @typedef {import('react-reconciler').Fiber} Fiber
* @typedef {import('react-reconciler').Source} Source
*/
/**
* @param {Fiber} instance
*/
export function getSourceForInstance({ _debugSource }) {
if (!_debugSource) return
const {
// It _does_ exist!
// @ts-ignore Property 'columnNumber' does not exist on type 'Source'.ts(2339)
columnNumber = 1,
fileName,
lineNumber = 1,
} = _debugSource
return { columnNumber, fileName, lineNumber }
}

View File

@@ -0,0 +1,7 @@
export { ClickToComponent } from './src/ClickToComponent'
export type Editor = 'vscode' | 'vscode-insiders'
export type ClickToComponentProps = {
editor?: Editor
}

3798
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff