[inula-dev-tools]<feat> Panel 组件合入

This commit is contained in:
13659257719 2023-11-20 16:27:53 +08:00
parent b5e1a137d4
commit 587f61eec3
4 changed files with 619 additions and 0 deletions

View File

@ -0,0 +1,117 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
@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: 0 0 var(--horizontal-percentage);
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.5rem 0 0.8rem;
flex: 0 0;
.Picking {
color: #0088fa;
}
.StopPicking {
color: #5f6673;
}
.StopPicking :hover {
color: #23272f;
}
}
.divider {
flex: 0 0 1px;
margin: 0 0.25rem 0 0.25rem;
border-left: @divider-style;
height: calc(100% - 1rem);
}
.search {
display: flex;
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;
}
}
.resizeBar {
flex: 0 0 0;
position: relative;
resize: horizontal;
.resizeLine {
position: absolute;
left: -2px;
width: 5px;
height: 100%;
cursor: ew-resize;
}
}
.right {
flex: 3;
overflow-x: hidden;
overflow-y: auto;
border-left: @divider-style;
}
input {
outline: none;
border-width: 0;
padding: 0;
}

View File

@ -0,0 +1,448 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
import {
useState,
useEffect,
useRef,
memo,
useMemo,
useCallback,
useReducer,
} from 'openinula';
import VTree, { IData } from '../components/VTree';
import Search from '../components/Search';
import ComponentInfo from '../components/ComponentInfo';
import styles from './Panel.less';
import Select from '../svgs/Select';
import { FilterTree } from '../hooks/FilterTree';
import Close from '../svgs/Close';
import Arrow from '../svgs/Arrow';
import {
AllVNodeTreeInfos,
RequestComponentAttrs,
ComponentAttrs,
PickElement,
StopPickElement,
} from '../utils/constants';
import {
addBackgroundMessageListener,
initBackgroundConnection,
postMessageToBackground,
removeBackgroundMessageListener,
} from '../panelConnection';
import { IAttr } from '../parser/parseAttr';
import { NameObj } from '../parser/parseVNode';
import { createLogger } from '../utils/logUtil';
import type { Source } from '../../../inula/src/renderer/Types';
import ViewSourceContext from '../utils/ViewSource';
import PickElementContext from '../utils/PickElement';
import Discover from '../svgs/Discover';
type ResizeActionType = 'START_RESIZE' | 'SET_HORIZONTAL_PERCENTAGE';
type ResizeAction = {
type: ResizeActionType;
payload: any;
};
type ResizeState = {
horizontalPercentage: number;
isResizing: boolean;
};
const logger = createLogger('panelApp');
let maxDeep = 0;
const parseVNodeData = (rawData, idToTreeNodeMap, nextIdToTreeNodeMap) => {
const indentationMap: {
[id: string]: number;
} = {};
const data: IData[] = [];
let i = 0;
while (i < rawData.length) {
const id = rawData[i] as number;
i++;
const name = rawData[i] as NameObj;
i++;
const parentId = rawData[i] as string;
i++;
const userKey = rawData[i] as string;
i++;
const indentation = parentId === '' ? 0 : indentationMap[parentId] + 1;
maxDeep = maxDeep >= indentation ? maxDeep : indentation;
indentationMap[id] = indentation;
const lastItem = idToTreeNodeMap[id];
if (lastItem) {
// 由于 diff 算法限制,一个 vNode 的 nameuserKeyindentation 属性不会发生变化
// 但是在跳转到新页面时, id 值重置,此时原有 id 对应的节点都发生了变化,需要更新
// 为了让架构尽可能简单,不区分是否是页面挑战,所以每次都需要重新赋值
nextIdToTreeNodeMap[id] = lastItem;
lastItem.name = name;
lastItem.indentation = indentation;
lastItem.userKey = userKey;
data.push(lastItem);
} else {
const item = {
id,
name,
indentation,
userKey,
};
nextIdToTreeNodeMap[id] = item;
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;
};
interface IIdToNodeMap {
[id: number]: IData;
}
/**
* dev tools
*
* @param {null | HTMLElement} resizeElement
* @param {number} percentage
*/
const setResizePCTForElement = (
resizeElement: null | HTMLElement,
percentage: number
): void => {
if (resizeElement !== null) {
resizeElement.style.setProperty(
'--horizontal-percentage',
`${percentage}`
);
}
};
function resizeReducer(state: ResizeState, action: ResizeAction): ResizeState {
switch (action.type) {
case "START_RESIZE":
return {
...state,
isResizing: action.payload,
};
case "SET_HORIZONTAL_PERCENTAGE":
return {
...state,
horizontalPercentage: action.payload,
};
default:
return state;
}
}
function initResizeState(): ResizeState {
const horizontalPercentage = 0.62;
return {
horizontalPercentage,
isResizing: false,
};
}
function Panel({ viewSource, inspectVNode }) {
const [parsedVNodeData, setParsedVNodeData] = useState([]);
const [componentAttrs, setComponentAttrs] = useState<{
parsedProps?: IAttr[];
parsedState?: IAttr[];
parsedHooks?: IAttr[];
}>({});
const [selectComp, setSelectComp] = useState<IData>(null);
const [isPicking, setPicking] = useState(false);
const [source, setSource] = useState<Source>(null);
const idToTreeNodeMapref = useRef<IIdToNodeMap>({});
const [state, dispatch] = useReducer(
resizeReducer,
null,
initResizeState
);
const pageRef = useRef<null | HTMLElement>(null);
const treeRef = useRef<null | HTMLElement>(null);
const { horizontalPercentage } = state;
const {
filterValue,
onChangeSearchValue: setFilterValue,
onClear,
currentItem,
matchItems,
onSelectNext,
onSelectLast,
setShowItems,
collapsedNodes,
setCollapsedNodes,
} = FilterTree({ data: parsedVNodeData });
useEffect(() => {
if (isDev) {
// const nextIdToTreeNodeMap: IIdToNodeMap = {};
} else {
const handleBackgroundMessage = message => {
const { payload } = message;
// 对象数据只是记录了引用,内容可能在后续被修改,打印字符串可以获取当前真正内容,不被后续修改影响
logger.info(JSON.stringify(payload));
if (payload) {
const {type, data} = payload;
if (type === AllVNodeTreeInfos) {
const idToTreeNodeMap = idToTreeNodeMapref.current;
const nextIdToTreeNodeMap: IIdToNodeMap = {};
const allTreeData = data.reduce((pre, current) => {
const parsedTreeData = parseVNodeData(
current,
idToTreeNodeMap,
nextIdToTreeNodeMap
);
return pre.concat(parsedTreeData);
}, []);
idToTreeNodeMapref.current = nextIdToTreeNodeMap;
setParsedVNodeData(allTreeData);
if (selectComp) {
postMessageToBackground(RequestComponentAttrs, selectComp.id);
}
} else if (type === ComponentAttrs) {
const { parsedProps, parsedState, parsedHooks, src } = data;
setComponentAttrs({
parsedProps,
parsedState,
parsedHooks,
});
setSource(src);
} else if (type === StopPickElement) {
setPicking(false);
} else if (type === PickElement) {
const target = Object.values(idToTreeNodeMapref.current).find(({ id }) => id == data);
setSelectComp(target);
}
}
};
// 在页面渲染后初始化连接
initBackgroundConnection('panel');
// 监听 background 消息
addBackgroundMessageListener(handleBackgroundMessage);
return () => {
removeBackgroundMessageListener(handleBackgroundMessage);
};
}
}, [selectComp]);
useEffect(() => {
const treeElement = treeRef.current;
setResizePCTForElement(treeElement, horizontalPercentage * 100);
}, []);
const handleSearchChange = (str: string) => {
setFilterValue(str);
};
const handleSelectComp = (item: IData) => {
setSelectComp(item);
if (isDev) {
// setComponentAttrs({});
} else {
postMessageToBackground(RequestComponentAttrs, item.id);
}
};
const handleClickParent = useCallback((item: IData) => {
setSelectComp(item);
}, []);
const onRendered = info => {
setShowItems(info.visibleItems);
};
const parents = useMemo(
() => getParents(selectComp, parsedVNodeData),
[selectComp, parsedVNodeData]
);
const viewSourceFunction = useMemo(
() => ({
viewSource: viewSource || null,
}),
[viewSource]
);
// 选择页面元素对应到 dev tools
const pickElementFunction = useMemo(
() => ({
inspectVNode: inspectVNode || null,
}),
[inspectVNode]
);
// 选择页面元素图标样式
let pickClassName;
if (isPicking) {
pickClassName = styles.Picking;
} else {
pickClassName = styles.StopPicking;
}
const MINIMUM_SIZE = 50;
const { isResizing } = state;
const doResize = () => dispatch({ type: 'START_RESIZE', payload: true });
let onResize;
let stopResize;
if (isResizing) {
stopResize = () => dispatch({ type: 'START_RESIZE', payload: false });
onResize = event => {
// 设置横向 resize 百分比区域(左树部分)
const treeElement = treeRef.current;
// 整个页面(左树部分加节点详情部分),要拿到页面宽度,防止 resize 时移出页面
const pageElement = pageRef.current;
if (isResizing || pageElement === null || treeElement === null) {
return;
}
// 左移时防止左树移出页面
event.preventDefault();
const { width, left } = pageElement.getBoundingClientRect();
const mouseAbscissa = event.clientX - left;
const pageSizeMin = MINIMUM_SIZE;
const pageSizeMax = width-MINIMUM_SIZE;
const isMouseInPage = mouseAbscissa > pageSizeMin && mouseAbscissa < pageSizeMax;
if (isMouseInPage) {
const resizedElementWidth = width;
const actionType = 'SET_HORIZONTAL_PERCENTAGE';
const percentage = (mouseAbscissa / resizedElementWidth) * 100;
setResizePCTForElement(treeElement, percentage);
dispatch({
type: actionType,
payload: mouseAbscissa / resizedElementWidth,
});
}
};
}
return (
<ViewSourceContext.Provider value={{ viewSourceFunction }}>
<PickElementContext.Provider value={{ pickElementFunction }}>
<div
ref={pageRef}
onMouseMove={onResize}
onMouseLeave={stopResize}
onMouseUp={stopResize}
className={styles.app}
>
<div ref={treeRef} className={styles.left}>
<div className={styles.leftTop}>
<div className={styles.select}>
<button className={`${pickClassName}`}>
<span
className={styles.eye}
title={'Pick an element from the page'}
onClick={() => {
postMessageToBackground(!isPicking ? PickElement : StopPickElement);
setPicking(!isPicking);
}}
>
<Select />
</span>
</button>
</div>
<div className={styles.divider} />
<div className={styles.search}>
<Discover />
<Search onKeyUp={onSelectNext} onChange={handleSearchChange} value={filterValue} />
</div>
{filterValue !== '' && (
<>
<span className={styles.searchResult}>
{`${matchItems.indexOf(currentItem) + 1}/${matchItems.length}`}
</span>
<div className={styles.divider} />
<button
className={styles.searchAction}
onClick={onSelectLast}
>
<Arrow direction={'up'} />
</button>
<button
className={styles.searchAction}
onClick={onSelectNext}
>
<Arrow direction={'down'} />
</button>
<button className={styles.searchAction} onClick={onClear}>
<Close />
</button>
</>
)}
</div>
<div>
<VTree
data={parsedVNodeData}
maxDeep={maxDeep}
highlightValue={filterValue}
onRendered={onRendered}
collapsedNodes={collapsedNodes}
onCollapseNode={setCollapsedNodes}
scrollToItem={currentItem}
selectItem={selectComp}
onSelectItem={handleSelectComp}
/>
</div>
</div>
<div>
<div onMouseDown={doResize} className={styles.resizeLine} />
</div>
<div>
<ComponentInfo
name={selectComp ? selectComp.name.itemName : null}
attrs={selectComp ? componentAttrs : {}}
parents={parents}
id={selectComp ? selectComp.id : null}
source={selectComp ? source : null}
onClickParent={handleClickParent}
/>
</div>
</div>
</PickElementContext.Provider>
</ViewSourceContext.Provider>
);
}
export default memo(Panel);

View File

@ -0,0 +1,19 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
import Panel from './Panel';
// 这里导出 Panel 为了加载 Panel.less
export default Panel;

View File

@ -0,0 +1,35 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<meta http-equiv="Content-Security-Policy"
content="default-src *; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' ">
<style>
html {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-y: hidden;
}
#root {
width: 100%;
height: 100%;
}
</style>
<script src="inula.development.js"></script>
</head>
<body>
<div id="root"></div>
<script src="panel.js"></script>
</body>
</html>