diff --git a/packages/inula-dev-tools/src/components/ComponentInfo.less b/packages/inula-dev-tools/src/components/ComponentInfo.less new file mode 100644 index 00000000..d8b8f432 --- /dev/null +++ b/packages/inula-dev-tools/src/components/ComponentInfo.less @@ -0,0 +1,279 @@ +/* + * 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 'assets.less'; + +.infoContainer { + display: flex; + flex-direction: column; + height: 100%; + + .button { + border: none; + padding: 0; + border-radius: 0.25rem; + flex: 0 0 auto; + cursor: pointer; + color: #5f6673; + } + + .button :hover { + color: #23272f; + } + + .componentInfoHead { + flex: 0 0 @top-height; + display: flex; + align-items: center; + border-bottom: @divider-style; + + .name { + flex: 1 1 auto; + padding: 0 1rem 0 1rem; + .text { + display: block; + } + } + + .eye { + flex: 0 0 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + } + + .debug { + flex: 0 0 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem 0.25rem 0; + } + + .location { + flex: 0 0 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem 0.25rem 0; + } + } + + .componentInfoMain { + overflow-y: auto; + + > :last-child { + border-bottom: unset; + } + + > div { + border-bottom: @divider-style; + } + + .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 { + margin-top: 1px; + color: @attr-name-color; + font-family: @attr-name-font-family; + } + + .colon { + margin-top: 1px; + transform: translateY(-8%); + margin-right: 0.5rem; + } + + .info { + display: flex; + &:hover { + .operation { + visibility: visible; + .operationIcon :hover { + border: none; + border-radius: 5px; + background-color: lightskyblue; + } + } + } + } + + .attrValue { + width: 26rem; + height: 1rem; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, + Courier, monospace; + &:focus { + color: unset; + background-color: #f0f0f0; + } + } + .attrValue[data-type='string'] { + color: #009906; + } + .attrValue[data-type='function'] { + color: royalblue; + } + .attrValue[data-type='number'] { + color: #ff5722; + } + .attrValue[data-type='boolean'] { + color: #03a9f4; + } + + .operation { + cursor: pointer; + visibility: hidden; + } + + .checkBox { + margin: 2px 3px 0 auto; + justify-content: flex-end; + } + } + } + + .dropdown.active { + display: unset; + top: var(--content-top); + left: var(--content-left); + position: absolute; + ul { + margin-block-start: 0; + padding-inline-start: 0; + li { + padding: 10px; + border-top: 1px lighten(#333, 2%) solid; + height: auto; + overflow: auto; + opacity: 1; + } + } + } + + .dropdown { + display: none; + + ul { + display: block; + position: relative; + list-style: none; + } + + li { + padding: 0 10px; + background: darken(#333, 2%); + color: darken(#EEE, 40%); + text-align: left; + border: 0; + width: 100%; + height: 0; + overflow: hidden; + cursor: pointer; + opacity: 0; + transition-property: all, background-color; + transition-duration: 0.2s, 0.4s; + + &:hover, &.selected { + background-color: darken(#333, 10%); + } + + &:active { + background: #03a9f4; + } + + &:first-child { + border-radius: 5px 5px 0 0; + } + + &:last-child { + border-radius: 0 0 5px 5px; + } + + &:before { + margin-top: -2px; + margin-right: 10px; + display: inline-block; + border-radius: 5px; + vertical-align: middle; + width: 16px; + height: 16px; + } + + &:nth-child(1) { + &:before { + content: url('../svgs/copy.svg'); + } + } + + &:nth-child(2) { + &:before { + content: url('../svgs/storage.svg'); + } + } + } + } + } + + .parentsInfo { + flex: 1 1 0; + + .parentName { + padding: 0.5rem 0.5rem 0 0.5rem; + } + + .parent { + margin-left: 1.4rem; + display: block; + cursor: pointer; + text-align: left; + color: @component-name-color; + + &:hover { + background-color: @select-color; + } + } + } +} diff --git a/packages/inula-dev-tools/src/components/ComponentInfo.tsx b/packages/inula-dev-tools/src/components/ComponentInfo.tsx new file mode 100644 index 00000000..0f2b658e --- /dev/null +++ b/packages/inula-dev-tools/src/components/ComponentInfo.tsx @@ -0,0 +1,435 @@ +/* + * 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 styles from './ComponentInfo.less'; +import Eye from '../svgs/Eye'; +import Debug from '../svgs/Debug'; +import Location from '../svgs/Location'; +import Triangle from '../svgs/Triangle'; +import { memo, useContext, useEffect, useState, useRef, useMemo, createRef } from 'openinula'; +import { IData } from './VTree'; +import { buildAttrModifyData, IAttr } from '../parser/parseAttr'; +import { postMessageToBackground } from '../panelConnection'; +import { CopyToConsole, InspectDom, LogComponentData, ModifyAttrs, StorageValue } from '../utils/constants'; +import type { Source } from '../../../inula/src/renderer/Types'; +import ViewSourceContext from '../utils/ViewSource'; +import PickElementContext from '../utils/PickElement'; +import Operation from '../svgs/Operation'; + +type IComponentInfo = { + name: string; + attrs: { + parsedProps?: IAttr[]; + parsedState?: IAttr[]; + parsedHooks?: IAttr[]; + }; + parents: IData[]; + id: number; + source?: Source; + onClickParent: (item: IData) => void; +}; + +const ComponentAttr = memo(function ComponentAttr({ + attrsName, + attrsType, + attrs, + id, + dropdownRef, + }: { + attrsName: string; + attrsType: string; + attrs: IAttr[]; + id: number; + dropdownRef: null | HTMLElement; +}) { + const [editableAttrs, setEditableAttrs] = useState(attrs); + const [expandNodes, setExpandNodes] = useState([]); + + useEffect(() => { + setEditableAttrs(attrs); + }, [attrs]); + + const handleCollapse = (item: IAttr) => { + const nodes = [...expandNodes]; + const expandItem = `${item.name}_${editableAttrs.indexOf(item)}`; + const i = nodes.indexOf(expandItem); + if (i === -1) { + nodes.push(expandItem); + } else { + nodes.splice(i, 1); + } + setExpandNodes(nodes); + }; + + // props 展示的 key: value 中的 value 值 + const getShowName = item => { + let retStr; + if (item === undefined) { + retStr = String(item); + } else if (typeof item === 'number') { + retStr = item; + } else if (typeof item === 'string') { + retStr = item.endsWith('>') ? `<${item}` : item; + } else { + retStr = `"${item}"`; + } + return retStr; + }; + + /** + * 拿到 props 或 hooks 在 VNode 里的路径 + * + * @param {Array} editableAttrs 所有 props 与 hooks 的值 + * @param {number} index 此值在 editableAttrs 的下标位置 + * @param {string} attrsType 此值属于 props 还是 hooks + * @return {Array} 值在 vNode 里的路径 + */ + const getPath = (editableAttrs: IAttr[], index: number, attrsType: string): Array => { + const path: Array = []; + let local = editableAttrs[index].indentation; + if (local === 1) { + path.push(attrsType === 'Hooks' ? editableAttrs[index].hIndex : editableAttrs[index].name); + } else { + let location = local; + let id = index; + while (location > 0) { + // local === 1 时处于 vNode.hooks 的子元素最外层 + if (location < local || id === index || local === 1) { + if (local === 1) { + attrsType === 'Hooks' + ? path.unshift(editableAttrs[id + 1].hIndex, 'state') + : path.unshift(editableAttrs[id + 1].name); + break; + } else { + if (editableAttrs[id]?.indentation === 1) { + if (editableAttrs[id]?.name === 'State') { + path.unshift('stateValue'); + } + if (editableAttrs[id]?.name === 'Ref') { + path.unshift('current'); + } + } else { + path.unshift(editableAttrs[id].name); + } + } + // 跳过同级 + local = location; + } + location = id >= 1 ? editableAttrs[id - 1].indentation : -1; + id = -1; + } + } + return path; + }; + + const showAttr = []; + let currentIndentation = null; + + // 为每一行数据添加一个 ref + const refsById = useMemo(() => { + const refs = {}; + editableAttrs.forEach((item, index) => { + refs[index] = createRef(); + }); + return refs; + }, [editableAttrs]); + + editableAttrs.forEach((item, index) => { + const operationRef = refsById[index]; + const indentation = item.indentation; + if (currentIndentation !== null) { + if (indentation > currentIndentation) { + return; + } else { + currentIndentation = null; + } + } + const nextItem = editableAttrs[index + 1]; + const hasChild = nextItem ? nextItem.indentation - item.indentation > 0 : false; + const isCollapsed = !expandNodes.includes(`${item.name}_${index}`); + + // 按钮点击事件 + const operationClick = (e: Event, operationRef: any) => { + // 防止点击按钮触发展开或者合起数据 + e.stopPropagation(); + if (operationRef.current) { + const operationRect = operationRef.current.getBoundingClientRect(); + // 19.2 为图标按钮高度,85 为弹框高度的一半 + dropdownRef.style.setProperty('--content-top', `${operationRect.top + 19.2}px`); + dropdownRef.style.setProperty('--content-left', `${operationRect.left - 85}px`); + } + dropdownRef.classList.toggle(styles['active']); + const attrInfo = { + id: { id }, + itemName: item.name, + attrsName: attrsName, + path: getPath(editableAttrs, index, attrsName), + }; + (dropdownRef as any).attrInfo = attrInfo; + console.log(dropdownRef); + }; + + showAttr.push( +
handleCollapse(item)} + > + {hasChild && } + {`${item.name}`} +
{':'}
+ {item.type === 'string' || item.type === 'number' || item.type === 'undefined' || item.type === 'null' ? ( + <> + { + const nextAttrs = [...editableAttrs]; + const nextItem = { ...item }; + nextItem.value = event.target.value; + nextAttrs[index] = nextItem; + setEditableAttrs(nextAttrs); + }} + onKeyUp={event => { + const value = (event.target as HTMLInputElement).value; + if (event.key === 'Enter') { + if (isDev) { + console.log('post attr change', value); + } else { + const data = buildAttrModifyData(attrsType, attrs, value, item, index, id); + postMessageToBackground(ModifyAttrs, data); + } + } + }} + /> +
+ operationClick(event, operationRef)}> + + +
+ + ) : item.type === 'boolean' ? ( + <> + + {item.value.toString()} + + { + const nextAttrs = [...editableAttrs]; + const nextItem = { ...item }; + nextItem.value = event.target.checked; + nextAttrs[index] = nextItem; + setEditableAttrs(nextAttrs); + if (!isDev) { + const data = buildAttrModifyData(attrsType, attrs, nextItem.value, item, index, id); + postMessageToBackground(ModifyAttrs, data); + } + }} + /> + + ) : ( + <> + + {item.value} + +
+ operationClick(event, operationRef)}> + + +
+ + )} +
+ ); + if (isCollapsed) { + currentIndentation = indentation; + } + }); + + return ( +
+
+ {attrsName} +
+
{showAttr}
+
+ ); +}); + +function ComponentInfo({ name, attrs, parents, id, source, onClickParent }: IComponentInfo) { + const view = useContext(ViewSourceContext) as any; + const viewSource = view.viewSourceFunction.viewSource; + + const pick = useContext(PickElementContext) as any; + const inspectVNode = pick.pickElementFunction.inspectVNode; + const dropdownRef = useRef(null); + + const doViewSource = (id: number) => { + postMessageToBackground(InspectDom, { id }); + setTimeout(function () { + inspectVNode(); + }, 100); + }; + + const doInspectDom = (id: number) => { + postMessageToBackground(InspectDom, { id }); + setTimeout(function () { + inspectVNode(); + }, 100); + }; + + const sourceFormatted = (fileName: string, lineNumber: number) => { + const pathWithoutLastName = /^(.*)[\\/]/; + + let realName = fileName.replace(pathWithoutLastName, ''); + if (/^index\./.test(realName)) { + const fileNameMatch = fileName.match(pathWithoutLastName); + if (fileNameMatch) { + const pathBeforeName = fileNameMatch[1]; + if (pathBeforeName) { + const folderName = pathBeforeName.replace(pathWithoutLastName, ''); + realName = folderName + '/' + realName; + } + } + } + + return `${realName}:${lineNumber}`; + }; + + const copyToConsole = (itemName: string | number, attrsName: string, path: Array) => { + postMessageToBackground(CopyToConsole, { id, itemName, attrsName, path }); + dropdownRef.current.classList.toggle(styles['active']); + }; + + const storeVariable = (attrsName: string, path: Array) => { + postMessageToBackground(StorageValue, { id, attrsName, path }); + dropdownRef.current.classList.toggle(styles['active']); + }; + + return ( +
+
+ {name && ( + <> +
+
{name}
+
+ + + + + + + + )} +
+
+ {Object.keys(attrs).map(attrsType => { + const parsedAttrs = attrs[attrsType]; + if (parsedAttrs && parsedAttrs.length !== 0) { + const attrsName = attrsType.slice(6); // parsedState => State + return ( + + ); + } + return null; + })} +
+ {name && ( +
+
Parents
+ {parents.map(item => ( + + ))} +
+ )} +
+
+ {source && ( + <> +
source: {''}
+
{sourceFormatted(source.fileName, source.lineNumber)}
+ + )} +
+
+
    +
  • + copyToConsole( + (dropdownRef.current as any).attrInfo.itemName, + (dropdownRef.current as any).attrInfo.attrsName, + (dropdownRef.current as any).attrInfo.path + ) + } + > + Copy value to console +
  • +
  • storeVariable((dropdownRef.current as any).attrInfo.attrsName, (dropdownRef.current as any).attrInfo.path)} + > + Store as global variable +
  • +
+
+
+
+ ); +} + +export default memo(ComponentInfo);