diff --git a/.eslintrc.js b/.eslintrc.js index 1a425fc3..f0692e11 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,19 +1,13 @@ module.exports = { extends: [ 'eslint:recommended', - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', 'prettier', ], root: true, - plugins: [ - 'jest', - 'no-for-of-loops', - 'no-function-declare-after-return', - 'react', - '@typescript-eslint', - ], + plugins: ['jest', 'no-for-of-loops', 'no-function-declare-after-return', 'react', '@typescript-eslint'], parser: '@typescript-eslint/parser', parserOptions: { @@ -34,7 +28,9 @@ module.exports = { rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', - 'semi': ["error", "always"], + '@typescript-eslint/no-empty-function': 'off', + semi: ['warn', 'always'], + quotes: ['warn', 'single'], 'accessor-pairs': 'off', 'brace-style': ['error', '1tbs'], 'func-style': ['warn', 'declaration', { allowArrowFunctions: true }], @@ -43,19 +39,18 @@ module.exports = { // 尾随逗号 'comma-dangle': ['error', 'only-multiline'], + 'no-constant-condition': 'off', 'no-for-of-loops/no-for-of-loops': 'error', 'no-function-declare-after-return/no-function-declare-after-return': 'error', }, globals: { - isDev: true + isDev: true, }, overrides: [ { - files: [ - 'scripts/__tests__/**/*.js' - ], + files: ['scripts/__tests__/**/*.js'], globals: { - container: true + container: true, }, }, ], diff --git a/libs/extension/readme.md b/libs/extension/readme.md new file mode 100644 index 00000000..d7e98884 --- /dev/null +++ b/libs/extension/readme.md @@ -0,0 +1,57 @@ +## 文件清单说明: +devtools_page: devtool主页面 +default_popup: 拓展图标点击时弹窗页面 +content_scripts: 内容脚本,在项目中负责在页面初始化时调用注入全局变量代码和消息传递 +web_accessible_resources: 注入全局变量代码 + +## 打开 panel 页面调试面板的方式 + +1. Open the developer tools. +1. Undock the developer tools if not already done (via the button in the bottom-left corner). +1. Press Ctrl + Shift + J to open the developer tools of the developer tools. +Optional: Feel free to dock the developer tools again if you had undocked it at step 2. +1. Switch from "" to devtoolsBackground.html (or whatever name you have chosen for your devtools). (example) +1. Now you can use the Console tab to play with the chrome.devtools API. + +## 全局变量注入 +通过content_scripts在document初始化时给页面添加script脚本,在新添加的脚本中给window注入全局变量 + +## horizon页面判断 +在页面完成渲染后往全局变量中添加信息,并传递 tabId 给 background 告知这是 horizon 页面 + +## 通信方式: +```mermaid +sequenceDiagram + participant web_page + participant script_content + participant background + participant panel + + Note over web_page: window.postMessage + web_page ->> script_content : {} + Note over script_content: window.addEventListener + Note over script_content: chrome.runtime.sendMessage + script_content ->> background : {} + Note over background: chrome.runtime.onMessage + Note over background: port.postMessage + background ->> panel : {} + Note over panel: connection.onMessage.addListener + Note over panel: connection.postMessage + panel ->> background : {} + Note over background: port.onMessage.addListener + Note over background: chrome.tabs.sendMessage + background ->> script_content : {} + Note over script_content: chrome.runtime.onMessage + Note over script_content: window.postMessage + script_content ->> web_page : {} + Note over web_page: window.addEventListener +``` + +## 数据压缩 +渲染组件树需要知道组件名和层次信息,如果把整个vNode树传递过来,传递对象太大,最好将数据进行压缩然后传递。 +- 相同的组件名可以进行压缩 +- 每个vNode有唯一的 path 属性,可以作为标识使用 +- 通过解析 path 值可以分析出组件树的结构 + +## 滚动动态渲染 Tree +考虑到组件树可能很大,所以并不适合一次性全部渲染出来,可以通过滚动渲染的方式减少页面 dom 的数量。我们可以把树看成有不同缩进长度的列表,动态渲染滚动列表的实现可以参考谷歌的这篇文章:https://developers.google.com/web/updates/2016/07/infinite-scroller 这样,我们需要的组件树数据可以由树结构转变为数组,可以减少动态渲染时对树结构进行解析时的计算工作。 diff --git a/libs/extension/src/components/ComponentInfo.tsx b/libs/extension/src/components/ComponentInfo.tsx new file mode 100644 index 00000000..10a1f637 --- /dev/null +++ b/libs/extension/src/components/ComponentInfo.tsx @@ -0,0 +1,126 @@ +import styles from './ComponentsInfo.less'; +import Eye from '../svgs/Eye'; +import Debug from '../svgs/Debug'; +import Copy from '../svgs/Copy'; +import Triangle from '../svgs/Triangle'; +import { useState } from 'horizon'; +import { IData } from './VTree'; + +type IComponentInfo = { + name: string; + attrs: { + props?: IAttr[]; + context?: IAttr[]; + state?: IAttr[]; + hooks?: IAttr[]; + }; + parents: IData[]; + onClickParent: (item: IData) => void; +}; + +type IAttr = { + name: string; + type: string; + value: string | boolean; + indentation: number; +} + +function collapseAllNodes(attrs: IAttr[]) { + return attrs.filter((item, index) => { + const nextItem = attrs[index + 1]; + return nextItem ? nextItem.indentation - item.indentation > 0 : false; + }); +} + +function ComponentAttr({ name, attrs }: { name: string, attrs: IAttr[] }) { + const [collapsedNode, setCollapsedNode] = useState(collapseAllNodes(attrs)); + const handleCollapse = (item: IAttr) => { + const nodes = [...collapsedNode]; + const i = nodes.indexOf(item); + if (i === -1) { + nodes.push(item); + } else { + nodes.splice(i, 1); + } + setCollapsedNode(nodes); + }; + + const showAttr = []; + let currentIndentation = null; + attrs.forEach((item, index) => { + const indentation = item.indentation; + if (currentIndentation !== null) { + if (indentation > currentIndentation) { + return; + } else { + currentIndentation = null; + } + } + const nextItem = attrs[index + 1]; + const hasChild = nextItem ? nextItem.indentation - item.indentation > 0 : false; + const isCollapsed = collapsedNode.includes(item); + showAttr.push( +
(handleCollapse(item))}> + {hasChild && } + {`${item.name}`} + {' :'} + {item.value} +
+ ); + if (isCollapsed) { + currentIndentation = indentation; + } + }); + + return ( +
+
+ {name} + + + +
+
+ {showAttr} +
+
+ ); +} + +export default function ComponentInfo({ name, attrs, parents, onClickParent }: IComponentInfo) { + const { state, props, context, hooks } = attrs; + return ( +
+
+ {name && <> + + {name} + + + + + + + + } +
+
+ {context && } + {props && } + {state && } + {hooks && } +
+ {name &&
+ parents: { + parents.map(item => ()) + } +
} +
+
+
+ ); +} \ No newline at end of file diff --git a/libs/extension/src/components/ComponentsInfo.less b/libs/extension/src/components/ComponentsInfo.less new file mode 100644 index 00000000..9a52e2fb --- /dev/null +++ b/libs/extension/src/components/ComponentsInfo.less @@ -0,0 +1,98 @@ +@import 'assets.less'; + +.infoContainer { + display: flex; + flex-direction: column; + height: 100%; + + + .componentInfoHead { + flex: 0 0 @top-height; + display: flex; + align-items: center; + border-bottom: @divider-style; + + .name { + flex: 1 1 0; + padding: 0 1rem 0 1rem; + } + + .eye { + flex: 0 0 1rem; + padding-right: 1rem; + } + + .debug { + flex: 0 0 1rem; + padding-right: 1rem; + } + } + + + .componentInfoMain { + overflow-y: auto; + + >:last-child { + border-bottom: unset; + } + + >:first-child { + padding: unset; + } + + >div { + border-bottom: @divider-style; + padding: 0.5rem + } + + .attrContainer { + flex: 0 0; + + .attrHead { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.5rem 0.5rem 0 0.5rem; + + .attrType { + flex: 1 1 0; + } + .attrCopy { + flex: 0 0 1rem; + padding-right: 1rem; + } + } + .attrDetail { + padding-bottom: 0.5rem; + + .attrArrow { + color: @arrow-color; + width: 12px; + display: inline-block; + } + + .attrName { + color: @attr-name-color; + } + + .attrValue { + margin-left: 4px; + } + } + } + } + + .parentsInfo { + flex: 1 1 0; + .parent { + display: block; + cursor: pointer; + text-align: left; + color: @component-name-color; + width: 100%; + &:hover { + background-color: @select-color; + } + } + } +} diff --git a/libs/extension/src/components/FilterTree.ts b/libs/extension/src/components/FilterTree.ts new file mode 100644 index 00000000..446bd05d --- /dev/null +++ b/libs/extension/src/components/FilterTree.ts @@ -0,0 +1,147 @@ +// 过滤树的抽象逻辑 +// 需要知道渲染了哪些数据,过滤的字符串/正则表达式 +// 控制Tree组件位置跳转,告知匹配结果 +// 清空搜索框,告知搜索框当前是第几个结果,跳转搜索结果 +// +// 跳转搜索结果的交互逻辑: +// 如果当前页面存在匹配项,页面不动 +// 如果当前页面不存在匹配项,页面跳转到第一个匹配项位置 +// 如果匹配项被折叠,需要展开其父节点。注意只展开当前匹配项的父节点,其他匹配项的父节点不展开 +// 跳转到上一个匹配项或下一个匹配项时,如果匹配项被折叠,需要展开其父节点 +// +// 寻找父节点: +// 找到该节点的缩进值,和index值,在data中向上遍历,通过缩进值判断父节点 + +import { useState, useRef } from 'horizon'; +import { createRegExp } from '../utils'; + +/** + * 把节点的父节点从收起节点数组中删除,并返回新的收起节点数组 + * + * @param item 需要展开父节点的节点 + * @param data 全部数据 + * @param collapsedNodes 收起节点数据 + * @returns 新的收起节点数组 + */ +function expandItemParent(item: BaseType, data: BaseType[], collapsedNodes: BaseType[]): BaseType[] { + const index = data.indexOf(item); + let currentIndentation = item.indentation; + // 不对原始数据进行修改 + const newCollapsedNodes = [...collapsedNodes]; + for (let i = index - 1; i >= 0; i--) { + const lastData = data[i]; + const lastIndentation = lastData.indentation; + // 缩进更小,找到了父节点 + if (lastIndentation < currentIndentation) { + // 更新缩进值,只找父节点的父节点,避免修改父节点的兄弟节点的展开状态 + currentIndentation = lastIndentation; + const cIndex = newCollapsedNodes.indexOf(lastData); + if (cIndex !== -1) { + newCollapsedNodes.splice(cIndex, 1); + } + } + } + return newCollapsedNodes; +} + +type BaseType = { + id: string, + name: string, + indentation: number, +} + +export function FilterTree(props: { data: T[] }) { + const { data } = props; + const [filterValue, setFilterValue] = useState(''); + const [currentItem, setCurrentItem] = useState(null); // 当前选中的匹配项 + const showItemsRef = useRef([]); // 页面展示的 items + const matchItemsRef = useRef([]); // 匹配过滤条件的 items + const collapsedNodesRef = useRef([]); // 折叠节点,如果匹配 item 被折叠了,需要展开 + + const matchItems = matchItemsRef.current; + const collapsedNodes = collapsedNodesRef.current; + + const updateCollapsedNodes = (item: BaseType) => { + const newcollapsedNodes = expandItemParent(item, data, collapsedNodes); + // 如果新旧收起节点数组长度不一样,说明存在收起节点 + if (newcollapsedNodes.length !== collapsedNodes.length) { + // 更新引用,确保 VTree 拿到新的 collapsedNodes + collapsedNodesRef.current = newcollapsedNodes; + } + }; + + const onChangeSearchValue = (search: string) => { + const reg = createRegExp(search); + let newCurrentItem = null; + let newMatchItems = []; + if (search !== '') { + const showItems: T[] = showItemsRef.current; + newMatchItems = data.reduce((pre, current) => { + const { name } = current; + if (reg && name.match(reg)) { + pre.push(current); + // 如果当前页面显示的 item 存在匹配项,则把它设置为 currentItem + if (newCurrentItem === null && showItems.includes(current)) { + newCurrentItem = current; + } + } + return pre; + }, []); + if (newMatchItems.length === 0) { + setCurrentItem(null); + } else { + if (newCurrentItem === null) { + const item = newMatchItems[0]; + // 不处于当前展示页面,需要展开父节点 + updateCollapsedNodes(item); + setCurrentItem(item); + } else { + setCurrentItem(newCurrentItem); + } + } + } else { + setCurrentItem(null); + } + matchItemsRef.current = newMatchItems; + setFilterValue(search); + }; + const onSelectNext = () => { + const index = matchItems.indexOf(currentItem); + const nextIndex = index + 1; + const item = nextIndex < matchItemsRef.current.length ? matchItems[nextIndex] : matchItems[0]; + // 可能不处于当前展示页面,需要展开父节点 + updateCollapsedNodes(item); + setCurrentItem(item); + }; + const onSelectLast = () => { + const index = matchItems.indexOf(currentItem); + const last = index - 1; + const item = last >= 0 ? matchItems[last] : matchItems[matchItems.length - 1]; + // 可能不处于当前展示页面,需要展开父节点 + updateCollapsedNodes(item); + setCurrentItem(item); + }; + const setShowItems = (items) => { + showItemsRef.current = [...items]; + }; + const onClear = () => { + onChangeSearchValue(''); + }; + const setCollapsedNodes = (items) => { + // 不更新引用,避免子组件的重复渲染 + collapsedNodesRef.current.length = 0; + collapsedNodesRef.current.push(...items); + }; + return { + filterValue, + onChangeSearchValue, + onClear, + currentItem, + matchItems, + onSelectNext, + onSelectLast, + setShowItems, + collapsedNodes, + setCollapsedNodes, + }; +} diff --git a/libs/extension/src/components/ResizeEvent.ts b/libs/extension/src/components/ResizeEvent.ts new file mode 100644 index 00000000..0150d0ad --- /dev/null +++ b/libs/extension/src/components/ResizeEvent.ts @@ -0,0 +1,78 @@ +/** + * + * 由于 ResizeObserver 对 IE 和低版本主流浏览器不兼容,需要我们自己解决这个问题。 + * 这是一个不依赖任何框架的监听 dom 元素尺寸变化的解决方案。 + * 浏览器出于性能的考虑,只有 window 的 resize 事件会触发。我们通过 object 标签可以得到 + * 一个 window 对象,让 object dom 元素成为待观测 dom 的子元素,并且和待观测 dom 大小一致。 + * 这样一旦待观测 dom 的大小发生变化, window 的大小也会发生变化,我们就可以通过监听 window + * 大小变化的方式监听待观测 dom 的大小变化。 + * + *
+ * --> 和父 div 保持大小一致 + * --> 添加 resize 事件监听 + * + *
+ * + */ + +function timeout(fn) { + return setTimeout(fn, 20); +} + +function requestFrame(fn) { + const raf = requestAnimationFrame || timeout; + return raf(fn); +} + +function cancelFrame(id) { + const cancel = cancelAnimationFrame || clearTimeout; + cancel(id); +} + +// 在闲置帧触发回调事件,如果在本次触发前存在未处理回调事件, +// 需要取消未处理的回调事件 +function resizeListener(event) { + const win = event.target; + if (win.__resizeRAF__) { + cancelFrame(win.__resizeRAF__); + } + win.__resizeRAF__ = requestFrame(function () { + const observeElement = win.__observeElement__; + observeElement.__resizeCallbacks__.forEach(function (fn) { + fn.call(observeElement, observeElement, event); + }); + }); +} + +function loadObserver() { + // 将待观测元素传递给 object 标签的 window 对象,这样在触发 resize 事件时可以拿到待观测元素 + this.contentDocument.defaultView.__observeElement__ = this.__observeElement__; + // 给 html 的 window 对象添加 resize 事件 + this.contentDocument.defaultView.addEventListener('resize', resizeListener); +} + +export function addResizeListener(element: any, fn: any) { + if (!element.__resizeCallbacks__) { + element.__resizeCallbacks__ = [fn]; + element.style.position = 'relative'; + const observer = document.createElement('object'); + observer.setAttribute('style', 'display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;'); + observer.data = 'about:blank'; + observer.onload = loadObserver; + observer.type = 'text/html'; + observer.__observeElement__ = element; + element.__observer__ = observer; + element.appendChild(observer); + } else { + element.__resizeCallbacks__.push(fn); + } +} + +export function removeResizeListener(element, fn) { + element.__resizeCallbacks__.splice(element.__resizeCallbacks__.indexOf(fn), 1); + if (!element.__resizeCallbacks__.length) { + element.__observer__.contentDocument.defaultView.removeEventListener('resize', resizeListener); + element.removeChild(element.__observer__); + element.__observer__ = null; + } +} diff --git a/libs/extension/src/components/Search.less b/libs/extension/src/components/Search.less new file mode 100644 index 00000000..003f8577 --- /dev/null +++ b/libs/extension/src/components/Search.less @@ -0,0 +1,3 @@ +.search { + width: 100%; +} diff --git a/libs/extension/src/components/Search.tsx b/libs/extension/src/components/Search.tsx new file mode 100644 index 00000000..1ea264d3 --- /dev/null +++ b/libs/extension/src/components/Search.tsx @@ -0,0 +1,21 @@ +import styles from './Search.less'; + +interface SearchProps { + onChange: (event: any) => void, + value: string, +} + +export default function Search(props: SearchProps) { + const { onChange, value } = props; + const handleChange = (event) => { + onChange(event.target.value); + }; + return ( + + ); +} \ No newline at end of file diff --git a/libs/extension/src/components/SizeObserver.tsx b/libs/extension/src/components/SizeObserver.tsx new file mode 100644 index 00000000..3d430093 --- /dev/null +++ b/libs/extension/src/components/SizeObserver.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState, useRef } from 'horizon'; +import { addResizeListener, removeResizeListener } from './ResizeEvent'; + + +export function SizeObserver(props) { + const { children, ...rest } = props; + const containerRef = useRef(); + const [size, setSize] = useState(); + const notifyChild = (element) => { + setSize({ + width: element.offsetWidth, + height: element.offsetHeight, + }); + }; + useEffect(() => { + const element = containerRef.current; + setSize({ + width: element.offsetWidth, + height: element.offsetHeight, + }); + addResizeListener(element, notifyChild); + return () => { + removeResizeListener(element, notifyChild); + }; + }, []); + const myChild = size ? children(size.width, size.height) : null; + + return ( +
+ {myChild} +
+ ); +} \ No newline at end of file diff --git a/libs/extension/src/components/VList.less b/libs/extension/src/components/VList.less new file mode 100644 index 00000000..8c14e471 --- /dev/null +++ b/libs/extension/src/components/VList.less @@ -0,0 +1,11 @@ +.container { + position: relative; + overflow-y: auto; + height: 100%; + width: 100%; +} + +.item { + position: absolute; + width: 100%; +} \ No newline at end of file diff --git a/libs/extension/src/components/VList.tsx b/libs/extension/src/components/VList.tsx new file mode 100644 index 00000000..c932a492 --- /dev/null +++ b/libs/extension/src/components/VList.tsx @@ -0,0 +1,98 @@ + +import { useState, useRef, useEffect } from 'horizon'; +import styles from './VList.less'; + +interface IProps { + data: T[], + width: number, // 暂时未用到,当需要支持横向滚动时使用 + height: number, // VList 的高度 + children: any, // horizon 组件,组件类型是 T + itemHeight: number, + scrollToItem?: T, // 滚动到指定项位置,如果该项在可见区域内,不滚动,如果不在,则滚动到中间位置 + onRendered: (renderInfo: renderInfoType) => void; + filter?(data: T): boolean, // false 表示该行不显示 +} + +export type renderInfoType = { + visibleItems: T[], + skipItemCountBeforeScrollItem: number, +}; + +export function VList(props: IProps) { + const { + data, + height, + children, + itemHeight, + scrollToItem, + filter, + onRendered, + } = props; + const [scrollTop, setScrollTop] = useState(data.indexOf(scrollToItem) * itemHeight); + const renderInfoRef: { current: renderInfoType } = useRef({ visibleItems: [], skipItemCountBeforeScrollItem: 0 }); + const containerRef = useRef(); + useEffect(() => { + onRendered(renderInfoRef.current); + }); + + useEffect(() => { + if (scrollToItem) { + const renderInfo = renderInfoRef.current; + // 在滚动区域,不滚动 + if (!renderInfo.visibleItems.includes(scrollToItem)) { + const index = data.indexOf(scrollToItem); + // top值计算需要减掉filter条件判定不显示项 + const totalCount = index - renderInfoRef.current.skipItemCountBeforeScrollItem; + // 显示在页面中间 + const top = totalCount * itemHeight - height / 2; + containerRef.current.scrollTo({ top: top }); + } + } + }, [scrollToItem]); + + const handleScroll = (event: any) => { + const scrollTop = event.target.scrollTop; + setScrollTop(scrollTop); + }; + const showList: any[] = []; + let totalHeight = 0; + // 顶部冗余 + const startShowTopValue = Math.max(scrollTop - itemHeight * 4, 0); + // 底部冗余 + const showNum = Math.floor(height / itemHeight) + 4; + // 如果最后一个显示不全,不统计在显示 ids 内 + const maxTop = scrollTop + height - itemHeight; + // 清空记录的上次渲染的数据 + renderInfoRef.current.visibleItems.length = 0; + const scrollItemIndex = data.indexOf(scrollToItem); + renderInfoRef.current.skipItemCountBeforeScrollItem = 0; + data.forEach((item, i) => { + if (filter && !filter(item)) { + if (scrollItemIndex > i) { + renderInfoRef.current.skipItemCountBeforeScrollItem++; + } + return; + } + if (totalHeight >= startShowTopValue && showList.length <= showNum) { + showList.push( +
+ {children(i, item)} +
+ ); + if (totalHeight >= scrollTop && totalHeight < maxTop) { + renderInfoRef.current.visibleItems.push(item); + } + } + totalHeight += itemHeight; + }); + + return ( +
+ {showList} +
+
+ ); +} diff --git a/libs/extension/src/components/VTree.less b/libs/extension/src/components/VTree.less new file mode 100644 index 00000000..0f34f9cd --- /dev/null +++ b/libs/extension/src/components/VTree.less @@ -0,0 +1,38 @@ +@import 'assets.less'; + +.treeContainer { + height: 100%; + + .treeItem { + width: 100%; + position: absolute; + line-height: 18px; + + &:hover { + background-color: @select-color; + } + + .treeIcon { + color: @arrow-color; + display: inline-block; + width: 12px; + padding-left: 0.5rem; + } + + .componentName { + color: @component-name-color; + } + + .componentKeyName { + color: @component-key-color; + } + + .componentKeyValue { + color: @componentKeyValue-color; + } + } + + .select { + background-color: rgb(141 199 248 / 60%); + } +} diff --git a/libs/extension/src/components/VTree.tsx b/libs/extension/src/components/VTree.tsx new file mode 100644 index 00000000..a286e411 --- /dev/null +++ b/libs/extension/src/components/VTree.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect } from 'horizon'; +import styles from './VTree.less'; +import Triangle from '../svgs/Triangle'; +import { createRegExp } from './../utils'; +import { SizeObserver } from './SizeObserver'; +import { renderInfoType, VList } from './VList'; + +export interface IData { + id: string; + name: string; + indentation: number; + userKey: string; +} + +interface IItem { + hasChild: boolean, + onCollapse: (data: IData) => void, + onClick: (id: IData) => void, + isCollapsed: boolean, + isSelect: boolean, + highlightValue: string, + data: IData, +} + +const indentationLength = 20; + +function Item(props: IItem) { + const { + hasChild, + onCollapse, + isCollapsed, + data, + onClick, + isSelect, + highlightValue = '', + } = props; + + const { + name, + userKey, + indentation, + } = data; + + const isShowKey = userKey !== ''; + const showIcon = hasChild ? : ''; + const handleClickCollapse = () => { + onCollapse(data); + }; + const handleClick = () => { + onClick(data); + }; + const itemAttr: any = { className: styles.treeItem, onClick: handleClick }; + if (isSelect) { + itemAttr.tabIndex = 0; + itemAttr.className = styles.treeItem + ' ' + styles.select; + } + const reg = createRegExp(highlightValue); + const heightCharacters = name.match(reg); + let showName; + if (heightCharacters) { + let cutName = name; + showName = []; + // 高亮第一次匹配即可 + const char = heightCharacters[0]; + const index = name.search(char); + const notHighlightStr = cutName.slice(0, index); + showName.push(notHighlightStr); + showName.push({char}); + cutName = cutName.slice(index + char.length); + showName.push(cutName); + } else { + showName = name; + } + return ( +
+
+ {showIcon} +
+ + {showName} + + {isShowKey && ( + <> + + {' '}key + + {'="'} + + {userKey} + + {'"'} + + )} +
+ ); +} + +function VTree(props: { + data: IData[], + highlightValue: string, + scrollToItem: IData, + onRendered: (renderInfo: renderInfoType) => void, + collapsedNodes?: IData[], + onCollapseNode?: (item: IData[]) => void, + selectItem: IData[], + onSelectItem: (item: IData) => void, +}) { + const { data, highlightValue, scrollToItem, onRendered, onCollapseNode, onSelectItem } = props; + const [collapseNode, setCollapseNode] = useState(props.collapsedNodes || []); + const [selectItem, setSelectItem] = useState(props.selectItem); + useEffect(() => { + setSelectItem(scrollToItem); + }, [scrollToItem]); + useEffect(() => { + if (props.selectItem !== selectItem) { + setSelectItem(props.selectItem); + } + }, [props.selectItem]); + useEffect(() => { + setCollapseNode(props.collapsedNodes || []); + }, [props.collapsedNodes]); + + const changeCollapseNode = (item: IData) => { + const nodes: IData[] = [...collapseNode]; + const index = nodes.indexOf(item); + if (index === -1) { + nodes.push(item); + } else { + nodes.splice(index, 1); + } + setCollapseNode(nodes); + if (onCollapseNode) { + onCollapseNode(nodes); + } + }; + const handleClickItem = (item: IData) => { + setSelectItem(item); + if (onSelectItem) { + onSelectItem(item); + } + }; + + let currentCollapseIndentation: null | number = null; + // 过滤掉折叠的 item,不展示在 VList 中 + const filter = (item: IData) => { + if (currentCollapseIndentation !== null) { + // 缩进更大,不显示 + if (item.indentation > currentCollapseIndentation) { + return false; + } else { + // 缩进小,说明完成了该收起节点的子节点处理。 + currentCollapseIndentation = null; + } + } + const isCollapsed = collapseNode.includes(item); + if (isCollapsed) { + // 该节点需要收起子节点 + currentCollapseIndentation = item.indentation; + } + return true; + }; + + return ( + + {(width: number, height: number) => { + return ( + + {(index: number, item: IData) => { + // 如果存在下一个节点,并且节点缩进比自己大,说明下个节点是子节点,节点本身需要显示展开收起图标 + const nextItem = data[index + 1]; + const hasChild = nextItem && nextItem.indentation > item.indentation; + return ( + + ); + }} + + ); + }} + + ); +} + +export default VTree; diff --git a/libs/extension/src/components/assets.less b/libs/extension/src/components/assets.less new file mode 100644 index 00000000..45e1b0c4 --- /dev/null +++ b/libs/extension/src/components/assets.less @@ -0,0 +1,15 @@ +@arrow-color: rgb(95, 99, 104); +@divider-color: rgb(202, 205, 209); +@attr-name-color: rgb(200, 0, 0); +@component-name-color: rgb(136, 18, 128); +@component-key-color: rgb(153, 69, 0); +@componentKeyValue-color: rgb(26, 26, 166); +@component-attr-color: rgb(200, 0, 0); +@select-color: rgb(141 199 248 / 60%); +@hover-color: black; + +@top-height: 2.625rem; +@divider-width: 0.2px; +@common-font-size: 12px; + +@divider-style: @divider-color solid @divider-width; diff --git a/libs/extension/src/devtools/mock.ts b/libs/extension/src/devtools/mock.ts new file mode 100644 index 00000000..3a3eef88 --- /dev/null +++ b/libs/extension/src/devtools/mock.ts @@ -0,0 +1,98 @@ +/** + * 用一个纯数据类型的对象 tree 去表示树的结构是非常清晰的,但是它不能准确的模拟 VNode 中存在的引用 + * 关系,需要进行转换 getMockVNodeTree + */ + +import { parseAttr } from '../parser/parseAttr'; +import parseTreeRoot from '../parser/parseVNode'; +import { VNode } from './../../../horizon/src/renderer/vnode/VNode'; +import { FunctionComponent, ClassComponent } from './../../../horizon/src/renderer/vnode/VNodeTags'; + +const mockComponentNames = ['Apple', 'Pear', 'Banana', 'Orange', 'Jenny', 'Kiwi', 'Coconut']; + +function MockVNode(tag: string, props = {}, key = null, realNode = {}) { + const vNode = new VNode(tag, props, key, realNode); + const name = mockComponentNames.shift() || 'MockComponent'; + vNode.type = { name }; + return vNode; +} + +interface IMockTree { + tag: string, + children?: IMockTree[], +} + +// 模拟树 +const tree: IMockTree = { + tag: ClassComponent, + children: [ + { tag: FunctionComponent }, + { tag: ClassComponent }, + { tag: FunctionComponent }, + { + tag: FunctionComponent, + children: [ + { tag: ClassComponent } + ] + } + ] +}; + +function addOneThousandNode(node: IMockTree) { + const nodes = []; + for (let i = 0; i < 1000; i++) { + nodes.push({ tag: FunctionComponent }); + } + node?.children.push({ tag: ClassComponent, children: nodes }); +} + +addOneThousandNode(tree); + +/** + * 将mock数据转变为 VNode 树 + * + * @param node 树节点 + * @param vNode VNode节点 + */ +function getMockVNodeTree(node: IMockTree, vNode: VNode) { + const children = node.children; + if (children && children.length !== 0) { + const childNode = children[0]; + let childVNode = MockVNode(childNode.tag); + childVNode.key = '0'; + getMockVNodeTree(childNode, childVNode); + // 需要建立双链 + vNode.child = childVNode; + childVNode.parent = vNode; + for (let i = 1; i < children.length; i++) { + const nextNode = children[i]; + const nextVNode = MockVNode(nextNode.tag); + nextVNode.key = String(i); + nextVNode.parent = vNode; + getMockVNodeTree(nextNode, nextVNode); + childVNode.next = nextVNode; + childVNode = nextVNode; + } + } +} +const rootVNode = MockVNode(tree.tag); +getMockVNodeTree(tree, rootVNode); + +export const mockParsedVNodeData = parseTreeRoot(rootVNode); + +const mockState = { + str: 'jenny', + num: 3, + boolean: true, + und: undefined, + fun: () => ({}), + symbol: Symbol('sym'), + map: new Map([['a', 'a']]), + set: new Set(['a', 1, 2, Symbol('bambi')]), + arr: [1, 2, 3, 4], + obj: { + niko: { jenny: 'jenny' } + } +}; + +export const parsedMockState = parseAttr(mockState); diff --git a/libs/extension/src/panel/App.less b/libs/extension/src/panel/App.less new file mode 100644 index 00000000..b974c557 --- /dev/null +++ b/libs/extension/src/panel/App.less @@ -0,0 +1,74 @@ +@import '../components/assets.less'; + +.app { + display: flex; + flex-direction: row; + height: 100%; + font-size: @common-font-size; +} + +button { + border: none; + background: none; + padding: 0; +} + +.left { + flex: 7; + display: flex; + flex-direction: column; + + .left_top { + border-bottom: @divider-style; + flex: 0 0 @top-height; + display: flex; + align-items: center; + padding-right: 0.4rem; + + .select { + padding: 0 0.25rem 0 0.25rem; + flex: 0 0; + } + + .divider { + flex: 0 0 1px; + margin: 0 0.25rem 0 0.25rem; + border-left: @divider-style; + height: calc(100% - 1rem); + } + + .search { + flex: 1 1 0; + } + + .searchResult{ + flex: 0 0 ; + padding: 0 0.4rem; + } + + .searchAction { + flex: 0 0 1rem; + height: 1rem; + color: @arrow-color; + &:hover{ + color: @hover-color; + } + } + } + + .left_bottom { + flex: 1; + height: 0; + } +} + +.right { + flex: 3; + border-left: @divider-style; +} + +input { + outline: none; + border-width: 0; + padding: 0; +} diff --git a/libs/extension/src/panel/App.tsx b/libs/extension/src/panel/App.tsx new file mode 100644 index 00000000..7cea62e9 --- /dev/null +++ b/libs/extension/src/panel/App.tsx @@ -0,0 +1,146 @@ +import { useState, useEffect } from 'horizon'; +import VTree, { IData } from '../components/VTree'; +import Search from '../components/Search'; +import ComponentInfo from '../components/ComponentInfo'; +import styles from './App.less'; +import Select from '../svgs/Select'; +import { mockParsedVNodeData, parsedMockState } from '../devtools/mock'; +import { FilterTree } from '../components/FilterTree'; +import Close from '../svgs/Close'; +import Arrow from './../svgs/Arrow'; + +const parseVNodeData = (rawData) => { + const idIndentationMap: { + [id: string]: number; + } = {}; + const data: IData[] = []; + let i = 0; + while (i < rawData.length) { + const id = rawData[i] as string; + i++; + const name = rawData[i] as string; + i++; + const parentId = rawData[i] as string; + i++; + const userKey = rawData[i] as string; + i++; + const indentation = parentId === '' ? 0 : idIndentationMap[parentId] + 1; + idIndentationMap[id] = indentation; + const item = { + id, name, indentation, userKey + }; + data.push(item); + } + return data; +}; + +const getParents = (item: IData | null, parsedVNodeData: IData[]) => { + const parents: IData[] = []; + if (item) { + const index = parsedVNodeData.indexOf(item); + let indentation = item.indentation; + for (let i = index; i >= 0; i--) { + const last = parsedVNodeData[i]; + const lastIndentation = last.indentation; + if (lastIndentation < indentation) { + parents.push(last); + indentation = lastIndentation; + } + } + } + return parents; +}; + +function App() { + const [parsedVNodeData, setParsedVNodeData] = useState([]); + const [componentAttrs, setComponentAttrs] = useState({}); + const [selectComp, setSelectComp] = useState(null); + + const { + filterValue, + onChangeSearchValue: setFilterValue, + onClear, + currentItem, + matchItems, + onSelectNext, + onSelectLast, + setShowItems, + collapsedNodes, + setCollapsedNodes, + } = FilterTree({ data: parsedVNodeData }); + + useEffect(() => { + if (isDev) { + const parsedData = parseVNodeData(mockParsedVNodeData); + setParsedVNodeData(parsedData); + setComponentAttrs({ + state: parsedMockState, + props: parsedMockState, + }); + } + }, []); + + const handleSearchChange = (str: string) => { + setFilterValue(str); + }; + + const handleSelectComp = (item: IData) => { + setComponentAttrs({ + state: parsedMockState, + props: parsedMockState, + }); + setSelectComp(item); + }; + + const handleClickParent = (item: IData) => { + setSelectComp(item); + }; + + const onRendered = (info) => { + setShowItems(info.visibleItems); + }; + const parents = getParents(selectComp, parsedVNodeData); + + return ( +
+
+
+
+