diff --git a/packages/inula-dev-tools/src/panel/Panel.less b/packages/inula-dev-tools/src/panel/Panel.less new file mode 100644 index 00000000..c68274a1 --- /dev/null +++ b/packages/inula-dev-tools/src/panel/Panel.less @@ -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; +} diff --git a/packages/inula-dev-tools/src/panel/Panel.tsx b/packages/inula-dev-tools/src/panel/Panel.tsx new file mode 100644 index 00000000..66a34896 --- /dev/null +++ b/packages/inula-dev-tools/src/panel/Panel.tsx @@ -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 的 name,userKey,indentation 属性不会发生变化 + // 但是在跳转到新页面时, 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(null); + const [isPicking, setPicking] = useState(false); + const [source, setSource] = useState(null); + const idToTreeNodeMapref = useRef({}); + const [state, dispatch] = useReducer( + resizeReducer, + null, + initResizeState + ); + const pageRef = useRef(null); + const treeRef = useRef(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 ( + + +
+
+
+
+