Match-id-5b8faa6494cb8dd94745d7e86e3e771222b7fd0e

This commit is contained in:
* 2022-04-01 14:46:03 +08:00 committed by *
commit 8c1b44cde9
28 changed files with 1653 additions and 0 deletions

57
libs/extension/readme.md Normal file
View File

@ -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 "<top frame>" 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 这样,我们需要的组件树数据可以由树结构转变为数组,可以减少动态渲染时对树结构进行解析时的计算工作。

View File

@ -0,0 +1,108 @@
import styles from './ComponentsInfo.less';
import Eye from '../svgs/Eye';
import Debug from '../svgs/Debug';
import Copy from '../svgs/Copy';
import Arrow from '../svgs/Arrow';
import { useState } from 'horizon';
type IComponentInfo = {
name: string;
attrs: {
props?: IAttr[];
context?: IAttr[];
state?: IAttr[];
hooks?: IAttr[];
}
};
type IAttr = {
name: string;
type: string;
value: string | boolean;
indentation: number;
}
function ComponentAttr({ name, attr }: { name: string, attr: IAttr[] }) {
const [collapsedNode, setCollapsedNode] = useState(new Set());
const handleCollapse = (index: number) => {
const newSet = new Set<number>();
collapsedNode.forEach(value => {
newSet.add(value);
});
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
setCollapsedNode(newSet);
};
const showAttr = [];
let currentIndentation = null;
attr.forEach((item, index) => {
const indentation = item.indentation;
if (currentIndentation !== null) {
if (indentation > currentIndentation) {
return;
} else {
currentIndentation = null;
}
}
const nextItem = attr[index + 1];
const hasChild = nextItem ? nextItem.indentation - item.indentation > 0 : false;
const isCollapsed = collapsedNode.has(index);
showAttr.push(
<div style={{ paddingLeft: item.indentation * 10 }} key={index} onClick={() => (handleCollapse(index))}>
<span className={styles.attrArrow}>{hasChild && <Arrow director={isCollapsed ? 'right' : 'down'} />}</span>
<span className={styles.attrName}>{`${item.name}`}</span>
{' :'}
<span className={styles.attrValue}>{item.value}</span>
</div>
);
if (isCollapsed) {
currentIndentation = indentation;
}
});
return (
<div className={styles.attrContainer}>
<div className={styles.attrHead}>
<span className={styles.attrType}>{name}</span>
<span className={styles.attrCopy}>
<Copy />
</span>
</div>
<div className={styles.attrDetail}>
{showAttr}
</div>
</div>
);
}
export default function ComponentInfo({ name, attrs }: IComponentInfo) {
const { state, props, context, hooks } = attrs;
return (
<div className={styles.infoContainer} >
<div className={styles.componentInfoHead}>
<span className={styles.name}>
{name}
</span>
<span className={styles.eye} >
<Eye />
</span>
<span className={styles.debug}>
<Debug />
</span>
</div>
<div className={styles.componentInfoMain}>
{context && <ComponentAttr name={'context'} attr={context} />}
{props && <ComponentAttr name={'props'} attr={props} />}
{state && <ComponentAttr name={'state'} attr={state} />}
{hooks && <ComponentAttr name={'hook'} attr={hooks} />}
<div className={styles.renderInfo}>
rendered by
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
@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;
}
}
}
.renderInfo {
flex: 1 1 0;
}
}
}

View File

@ -0,0 +1,3 @@
.search {
width: 100%;
}

View File

@ -0,0 +1,19 @@
import styles from './Search.less';
interface SearchProps {
onChange: (event: any) => void,
}
export default function Search(props: SearchProps) {
const { onChange } = props;
const handleChange = (event) => {
onChange(event.target.value);
};
return (
<input
onChange={handleChange}
className={styles.search}
placeholder={'Search (text or /regex/)'}
/>
);
}

View File

@ -0,0 +1,41 @@
@import 'assets.less';
.treeContainer {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
.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%);
}
}

View File

@ -0,0 +1,174 @@
import { useState } from 'horizon';
import styles from './VTree.less';
import Arrow from '../svgs/Arrow';
import { createRegExp } from './../utils';
export interface IData {
id: string;
name: string;
indentation: number;
userKey: string;
}
type IItem = {
style: any,
hasChild: boolean,
onCollapse: (id: string) => void,
onClick: (id: string) => void,
isCollapsed: boolean,
isSelect: boolean,
highlightValue: string,
} & IData
// TODO: 计算可以展示的最多数量,并且监听显示器高度变化修改数值
const showNum = 70;
const lineHeight = 18;
const indentationLength = 20;
function Item(props: IItem) {
const {
name,
style,
userKey,
hasChild,
onCollapse,
isCollapsed,
id,
indentation,
onClick,
isSelect,
highlightValue,
} = props;
const isShowKey = userKey !== '';
const showIcon = hasChild ? <Arrow director={isCollapsed ? 'right' : 'down'} /> : '';
const handleClickCollapse = () => {
onCollapse(id);
};
const handleClick = () => {
onClick(id);
};
const itemAttr: any = { style, 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(<mark>{char}</mark>);
cutName = cutName.slice(index + char.length);
showName.push(cutName);
} else {
showName = name;
}
return (
<div {...itemAttr}>
<div style={{ marginLeft: indentation * indentationLength }} className={styles.treeIcon} onClick={handleClickCollapse} >
{showIcon}
</div>
<span className={styles.componentName} >
{showName}
</span>
{isShowKey && (
<>
<span className={styles.componentKeyName}>
{' '}key
</span>
{'="'}
<span className={styles.componentKeyValue}>
{userKey}
</span>
{'"'}
</>
)}
</div>
);
}
function VTree({ data, highlightValue }: { data: IData[], highlightValue: string }) {
const [scrollTop, setScrollTop] = useState(0);
const [collapseNode, setCollapseNode] = useState(new Set<string>());
const [selectItem, setSelectItem] = useState();
const changeCollapseNode = (id: string) => {
const nodes = new Set<string>();
collapseNode.forEach(value => {
nodes.add(value);
});
if (nodes.has(id)) {
nodes.delete(id);
} else {
nodes.add(id);
}
setCollapseNode(nodes);
};
const handleClickItem = (id: string) => {
setSelectItem(id);
};
const showList: any[] = [];
let totalHeight = 0;
let currentCollapseIndentation: null | number = null;
data.forEach((item, index) => {
// 存在未处理完的收起节点
if (currentCollapseIndentation !== null) {
const indentation = item.indentation;
// 缩进更大,不显示
if (indentation > currentCollapseIndentation) {
return;
} else {
// 缩进小,说明完成了该收起节点的子节点处理。
currentCollapseIndentation = null;
}
}
const id = item.id;
const isCollapsed = collapseNode.has(id);
if (totalHeight >= scrollTop && showList.length <= showNum) {
const nextItem = data[index + 1];
// 如果存在下一个节点,并且节点缩进比自己大,说明下个节点是子节点,节点本身需要显示展开收起图标
const hasChild = nextItem ? nextItem.indentation > item.indentation : false;
showList.push(
<Item
key={id}
hasChild={hasChild}
style={{
transform: `translateY(${totalHeight}px)`,
}}
onCollapse={changeCollapseNode}
onClick={handleClickItem}
isCollapsed={isCollapsed}
isSelect={id === selectItem}
highlightValue={highlightValue}
{...item} />
);
}
totalHeight = totalHeight + lineHeight;
if (isCollapsed) {
// 该节点需要收起子节点
currentCollapseIndentation = item.indentation;
}
});
const handleScroll = (event: any) => {
const scrollTop = event.target.scrollTop;
// 顶部留 100px 冗余高度
setScrollTop(Math.max(scrollTop - 100, 0));
};
return (
<div className={styles.treeContainer} onScroll={handleScroll}>
{showList}
{/* 确保有足够的高度 */}
<div style={{ marginTop: totalHeight }} />
</div>
);
}
export default VTree;

View File

@ -0,0 +1,14 @@
@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%);
@top-height: 2.625rem;
@divider-width: 0.2px;
@common-font-size: 12px;
@divider-style: @divider-color solid @divider-width;

View File

@ -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);

View File

@ -0,0 +1,53 @@
@import '../components/assets.less';
.app {
display: flex;
flex-direction: row;
height: 100%;
font-size: @common-font-size;
}
.left {
flex: 7;
display: flex;
flex-direction: column;
.left_top {
border-bottom: @divider-style;
flex: 0 0 @top-height;
display: flex;
align-items: center;
.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;
}
}
.left_bottom {
flex: 1;
height: 0;
}
}
.right {
flex: 3;
border-left: @divider-style;
}
input {
outline: none;
border-width: 0;
padding: 0;
}

View File

@ -0,0 +1,74 @@
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';
function App() {
const [parsedVNodeData, setParsedVNodeData] = useState([]);
const [componentInfo, setComponentInfo] = useState({ name: null, attrs: {} });
const [filterValue, setFilterValue] = useState('');
useEffect(() => {
if (isDev) {
setParsedVNodeData(mockParsedVNodeData);
setComponentInfo({
name: 'Demo',
attrs: {
state: parsedMockState,
props: parsedMockState,
},
});
}
}, []);
const idIndentationMap: {
[id: string]: number;
} = {};
const data: IData[] = [];
let i = 0;
while (i < parsedVNodeData.length) {
const id = parsedVNodeData[i] as string;
i++;
const name = parsedVNodeData[i] as string;
i++;
const parentId = parsedVNodeData[i] as string;
i++;
const userKey = parsedVNodeData[i] as string;
i++;
const indentation = parentId === '' ? 0 : idIndentationMap[parentId] + 1;
idIndentationMap[id] = indentation;
const item = {
id, name, indentation, userKey
};
data.push(item);
}
const handleSearchChange = (str: string) => {
setFilterValue(str);
};
return (
<div className={styles.app}>
<div className={styles.left}>
<div className={styles.left_top} >
<div className={styles.select} >
<Select />
</div>
<div className={styles.divider} />
<div className={styles.search}>
<Search onChange={handleSearchChange} />
</div>
</div>
<div className={styles.left_bottom}>
<VTree data={data} highlightValue={filterValue} />
</div>
</div>
<div className={styles.right}>
<ComponentInfo name={componentInfo.name} attrs={componentInfo.attrs} />
</div>
</div>
);
}
export default App;

View File

@ -0,0 +1,7 @@
import { render } from 'horizon';
import App from './App';
render(
<App />,
document.getElementById('root')
);

View File

@ -0,0 +1,34 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<title>Horizon</title>
<script src='horizon.production.js'></script>
<style>
html {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
#root {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,83 @@
// 将状态的值解析成固定格式
export function parseAttr(rootAttr: any) {
const result: {
name: string,
type: string,
value: string,
indentation: number
}[] = [];
const indentation = 0;
const parseSubAttr = (attr: any, parentIndentation: number, attrName: string) => {
const stateType = typeof attr;
let value: any;
let showType;
let addSubState;
if (stateType === 'boolean' ||
stateType === 'number' ||
stateType === 'string' ||
stateType === 'undefined') {
value = attr;
showType = stateType;
} else if (stateType === 'function') {
const funName = attr.name;
value = `f() ${funName}{}`;
} else if (stateType === 'symbol') {
value = attr.description;
} else if (stateType === 'object') {
if (attr === null) {
showType = 'null';
} else if (attr instanceof Map) {
showType = 'map';
const size = attr.size;
value = `Map(${size})`;
addSubState = () => {
attr.forEach((value, key) => {
parseSubAttr(value, parentIndentation + 2, key);
});
};
} else if (attr instanceof Set) {
showType = 'set';
const size = attr.size;
value = `Set(${size})`;
addSubState = () => {
let i = 0;
attr.forEach((value) => {
parseSubAttr(value, parentIndentation + 2, String(i));
});
i++;
};
} else if (Array.isArray(attr)) {
showType = 'array';
value = `Array(${attr.length})`;
addSubState = () => {
attr.forEach((value, index) => {
parseSubAttr(value, parentIndentation + 2, String(index));
});
};
} else {
showType = stateType;
value = '{...}';
addSubState = () => {
Object.keys(attr).forEach((key) => {
parseSubAttr(attr[key], parentIndentation + 2, key);
});
};
}
}
result.push({
name: attrName,
type: showType,
value,
indentation: parentIndentation + 1,
});
if (addSubState) {
addSubState();
}
};
Object.keys(rootAttr).forEach(key => {
parseSubAttr(rootAttr[key], indentation, key);
});
return result;
}

View File

@ -0,0 +1,60 @@
import { travelVNodeTree } from '../../../../libs/horizon/src/renderer/vnode/VNodeUtils';
import { VNode } from '../../../../libs/horizon/src/renderer/Types';
import { ClassComponent, FunctionComponent } from '../../../../libs/horizon/src/renderer/vnode/VNodeTags';
// 建立双向映射关系,当用户在修改属性值后,可以找到对应的 VNode
const VNodeToIdMap = new Map<VNode, number>();
const IdToVNodeMap = new Map<number, VNode>();
let uid = 0;
function generateUid () {
uid++;
return uid;
}
function isUserComponent(tag: string) {
// TODO: 添加其他组件
return tag === ClassComponent || tag === FunctionComponent;
}
function getParentUserComponent(node: VNode) {
let parent = node.parent;
while(parent) {
if (isUserComponent(parent.tag)) {
break;
}
parent = parent.parent;
}
return parent;
}
function parseTreeRoot(treeRoot: VNode) {
const result: any[] = [];
travelVNodeTree(treeRoot, (node: VNode) => {
const tag = node.tag;
if (isUserComponent(tag)) {
const id = generateUid();
result.push(id);
const name = node.type.name;
result.push(name);
const parent = getParentUserComponent(node);
if (parent) {
const parentId = VNodeToIdMap.get(parent);
result.push(parentId);
} else {
result.push('');
}
const key = node.key;
if (key !== null) {
result.push(key);
} else {
result.push('');
}
VNodeToIdMap.set(node, id);
IdToVNodeMap.set(id, node);
}
}, null, treeRoot, null);
return result;
}
export default parseTreeRoot;

View File

@ -0,0 +1,17 @@
interface IArrow {
director: 'right' | 'down'
}
export default function Arrow({ director }: IArrow) {
let d: string;
if (director === 'right') {
d = 'm2 0l12 8l-12 8 z';
} else if (director === 'down') {
d = 'm0 2h16 l-8 12 z';
}
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='8px' height='8px'>
<path d={d} fill='currentColor' />
</svg>
);
}

View File

@ -0,0 +1,8 @@
export default function Copy() {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='1rem' height='1rem'>
<path d='M0 0 H16 V16 H0 z M2 2 H8 V8 H2 V2z' fill='currentColor' fill-rule='evenodd' />
</svg>
);
}

View File

@ -0,0 +1,8 @@
export default function Debug() {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='1rem' height='1rem'>
<path d='m2 0l12 8l-12 8 z' fill='#000'/>
</svg>
);
}

View File

@ -0,0 +1,10 @@
export default function Eye() {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='1rem' height='1rem'>
<ellipse cx="8" cy="8" rx="8" ry="6" />
<circle cx="8" cy="8" r="4" fill="rgb(255, 255, 255)" />
<circle cx="8" cy="8" r="2" fill="#000000" />
</svg>
);
}

View File

@ -0,0 +1,8 @@
export default function Select() {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='1rem' height='1rem'>
<path d='M14 6 V3 C14 2.5 13.5 2 13 2 H3 C2.5 2 2 2.5 2 3 V13 C2 13.5 2.5 14 3 14H6 V13 H3 V3 H13 V6z M7 7 L9 15 L11 12 L14 15 L15 14 L12 11 L15 9z' fill='#000' />
</svg>
);
}

View File

@ -0,0 +1,15 @@
export function createRegExp(expression: string) {
let str = expression;
if (str[0] === '/') {
str = str.slice(1);
}
if (str[str.length - 1] === '/') {
str = str.slice(0, str.length - 1);
}
try {
return new RegExp(str, 'i');
} catch (err) {
return null;
}
}

View File

@ -0,0 +1,76 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
// 用于 panel 页面开发
module.exports = {
mode: 'development',
entry: {
panel: path.join(__dirname, './src/panel/index.tsx'),
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js'
},
devtool: 'source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env',
'@babel/preset-typescript',
['@babel/preset-react', {
runtime: 'classic',
'pragma': 'Horizon.createElement',
'pragmaFrag': 'Horizon.Fragment',
}]],
plugins: ['@babel/plugin-proposal-class-properties'],
}
}
]
},
{
test: /\.less/i,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
}
},
'less-loader'],
}]
},
externals: {
'horizon': 'Horizon',
},
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
open: 'panel.html',
port: 9000,
magicHtml: true,
},
plugins: [
new HtmlWebpackPlugin({
filename: 'panel.html',
template: './src/panel/panel.html'
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"',
isDev: 'true',
}),
],
};

View File

@ -0,0 +1,131 @@
import * as Horizon from '@cloudsop/horizon/index.ts';
import * as LogUtils from '../jest/logUtils';
import * as TestUtils from '../jest/testUtils';
describe('事件', () => {
it('根节点挂载全量事件', () => {
const App = () => {
return <div />;
}
Horizon.render(<App />, container);
console.log(TestUtils.getEventListeners(container));
})
it('事件捕获与冒泡', () => {
const App = () => {
return (
<>
<div onClickCapture={() => LogUtils.log('div capture')} onClick={() => LogUtils.log('div bubble')}>
<p onClickCapture={() => LogUtils.log('p capture')} onClick={() => LogUtils.log('p bubble')}>
<button onClickCapture={() => LogUtils.log('btn capture')} onClick={() => LogUtils.log('btn bubble')} />
</p>
</div>
</>
);
}
Horizon.render(<App />, container);
const a = container.querySelector('button');
a.click();
expect(LogUtils.getAndClear()).toEqual([
// 从外到内先捕获再冒泡
'div capture',
'p capture',
'btn capture',
'btn bubble',
'p bubble',
'div bubble'
]);
})
it('returns 0', () => {
let keyCode = null;
const node = Horizon.render(
<input
onKeyPress={e => {
keyCode = e.keyCode;
}}
/>,
container,
);
node.dispatchEvent(
new KeyboardEvent('keypress', {
keyCode: 65,
bubbles: true,
cancelable: true,
}),
);
expect(keyCode).toBe(65);
});
it('阻止事件冒泡', () => {
const App = () => {
return (
<>
<div onClickCapture={() => LogUtils.log('div capture')} onClick={() => LogUtils.log('div bubble')}>
<p onClickCapture={() => LogUtils.log('p capture')} onClick={() => LogUtils.log('p bubble')}>
<button onClickCapture={() => LogUtils.log('btn capture')} onClick={(e) => TestUtils.stopBubbleOrCapture(e, 'btn bubble')} />
</p>
</div>
</>
);
}
Horizon.render(<App />, container);
container.querySelector('button').click();
expect(LogUtils.getAndClear()).toEqual([
// 到button时停止冒泡
'div capture',
'p capture',
'btn capture',
'btn bubble'
]);
})
it('阻止事件捕获', () => {
const App = () => {
return (
<>
<div onClickCapture={(e) => TestUtils.stopBubbleOrCapture(e, 'div capture')} onClick={() => LogUtils.log('div bubble')}>
<p onClickCapture={() => LogUtils.log('p capture')} onClick={() => LogUtils.log('p bubble')}>
<button onClickCapture={() => LogUtils.log('btn capture')} onClick={() => LogUtils.log('btn bubble')} />
</p>
</div>
</>
);
}
Horizon.render(<App />, container);
container.querySelector('button').click();
expect(LogUtils.getAndClear()).toEqual([
// 阻止捕获,不再继续向下执行
'div capture'
]);
})
it('阻止原生事件冒泡', () => {
const App = () => {
return (
<div>
<p>
<button />
</p>
</div>
);
}
Horizon.render(<App />, container);
container.querySelector('div').addEventListener('click', () => {
LogUtils.log('div bubble');
}, false);
container.querySelector('p').addEventListener('click', () => {
LogUtils.log('p bubble');
}, false);
container.querySelector('button').addEventListener('click', (e) => {
LogUtils.log('btn bubble');
e.stopPropagation();
}, false);
container.querySelector('button').click();
expect(LogUtils.getAndClear()).toEqual([
'btn bubble'
]);
})
})

View File

@ -0,0 +1,46 @@
import * as Horizon from '@cloudsop/horizon/index.ts';
import * as LogUtils from '../jest/logUtils';
import { act } from '../jest/customMatcher';
describe('合成焦点事件', () => {
it('onFocus', () => {
const realNode = Horizon.render(
<input
onFocus={event => LogUtils.log(`onFocus: ${event.type}`)}
onFocusCapture={event => LogUtils.log(`onFocusCapture: ${event.type}`)}
/>, container);
realNode.dispatchEvent(
new FocusEvent('focusin', {
bubbles: true,
cancelable: false,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onFocusCapture: focus',
'onFocus: focus',
]);
});
it('onBlur', () => {
const realNode = Horizon.render(
<input
onBlur={event => LogUtils.log(`onBlur: ${event.type}`)}
onBlurCapture={event => LogUtils.log(`onBlurCapture: ${event.type}`)}
/>, container);
realNode.dispatchEvent(
new FocusEvent('focusout', {
bubbles: true,
cancelable: false,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onBlurCapture: blur',
'onBlur: blur',
]);
})
})

View File

@ -0,0 +1,179 @@
import * as Horizon from '@cloudsop/horizon/index.ts';
import * as LogUtils from '../jest/logUtils';
describe('Keyboard Event', () => {
it('keydown,keypress,keyup的keycode,charcode', () => {
const node = Horizon.render(
<input
onKeyUp={(e) => {
LogUtils.log('onKeyUp: keycode: ' + e.keyCode + ',charcode: ' + e.charCode);
}}
onKeyDown={(e) => {
LogUtils.log('onKeyDown: keycode: ' + e.keyCode + ',charcode: ' + e.charCode)
}}
/>,
container,
);
node.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: 50,
code: 'Digit2',
bubbles: true,
cancelable: true,
}),
);
node.dispatchEvent(
new KeyboardEvent('keyup', {
keyCode: 50,
code: 'Digit2',
bubbles: true,
cancelable: true,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onKeyDown: keycode: 50,charcode: 0',
'onKeyUp: keycode: 50,charcode: 0'
]);
});
it('keypress的keycode,charcode', () => {
const node = Horizon.render(
<input
onKeyPress={(e) => {
LogUtils.log('onKeyPress: keycode: ' + e.keyCode + ',charcode: ' + e.charCode);
}}
/>,
container,
);
node.dispatchEvent(
new KeyboardEvent('keypress', {
charCode: 50,
code: 'Digit2',
bubbles: true,
cancelable: true,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onKeyPress: keycode: 0,charcode: 50'
]);
});
it('当charcode为13,且不设置keycode的时候', () => {
const node = Horizon.render(
<input
onKeyPress={(e) => {
LogUtils.log('onKeyPress: keycode: ' + e.keyCode + ',charcode: ' + e.charCode);
}}
/>,
container,
);
node.dispatchEvent(
new KeyboardEvent('keypress', {
charCode: 13,
bubbles: true,
cancelable: true,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onKeyPress: keycode: 0,charcode: 13'
]);
});
it('keydown,keypress,keyup的code', () => {
const node = Horizon.render(
<input
onKeyUp={(e) => {
LogUtils.log('onKeyUp: code: ' + e.code);
}}
onKeyPress={(e) => {
LogUtils.log('onKeyPress: code: ' + e.code);
}}
onKeyDown={(e) => {
LogUtils.log('onKeyDown: code: ' + e.code);
}}
/>,
container,
);
node.dispatchEvent(
new KeyboardEvent('keydown', {
code: 'Digit2',
bubbles: true,
cancelable: true,
}),
);
node.dispatchEvent(
new KeyboardEvent('keypress', {
keyCode: 50,
code: 'Digit2',
bubbles: true,
cancelable: true,
}),
);
node.dispatchEvent(
new KeyboardEvent('keyup', {
code: 'Digit2',
bubbles: true,
cancelable: true,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onKeyDown: code: Digit2',
'onKeyPress: code: Digit2',
'onKeyUp: code: Digit2'
]);
});
it('可以执行preventDefault和 stopPropagation', () => {
const keyboardProcessing = e => {
expect(e.isDefaultPrevented()).toBe(false);
e.preventDefault();
expect(e.isDefaultPrevented()).toBe(true);
expect(e.isPropagationStopped()).toBe(false);
e.stopPropagation();
expect(e.isPropagationStopped()).toBe(true);
LogUtils.log(e.type + ' handle');
};
const div = Horizon.render(
<div
onKeyDown={keyboardProcessing}
onKeyUp={keyboardProcessing}
onKeyPress={keyboardProcessing}
/>,
container,
);
div.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: 40,
bubbles: true,
cancelable: true,
}),
);
div.dispatchEvent(
new KeyboardEvent('keyup', {
keyCode: 40,
bubbles: true,
cancelable: true,
}),
);
div.dispatchEvent(
new KeyboardEvent('keypress', {
charCode: 40,
bubbles: true,
cancelable: true,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'keydown handle',
'keyup handle',
'keypress handle'
]);
});
});

View File

@ -0,0 +1,160 @@
import * as Horizon from '@cloudsop/horizon/index.ts';
import * as LogUtils from '../jest/logUtils';
describe('MouseEvent Test', () => {
describe('onClick Test', () => {
it('绑定this', () => {
class App extends Horizon.Component {
constructor(props) {
super(props);
this.state = {
num: this.props.num,
price: this.props.price
};
}
setNum() {
this.setState(
{
num: this.state.num + 1
}
)
}
setPrice = (e) => {
this.setState(
{
num: this.state.price + 1
}
)
}
render() {
return (
<>
<p>{this.state.num}</p>
<p id="p">{this.state.price}</p>
<button onClick={this.setNum.bind(this)} >button</button>
<button id="btn" onClick={() => this.setPrice()} >button</button>
</>
);
}
}
Horizon.render(<App num={0} price={100} />, container);
expect(container.querySelector('p').innerHTML).toBe('0');
expect(container.querySelector('#p').innerHTML).toBe('100');
// 点击按钮触发num加1
container.querySelector('button').click();
expect(container.querySelector('p').innerHTML).toBe('1');
container.querySelector('#btn').click();
expect(container.querySelector('p').innerHTML).toBe('101');
});
it('点击触发', () => {
const handleClick = jest.fn();
Horizon.render(<button onClick={handleClick}>Click Me</button>, container)
container.querySelector('button').click();
expect(handleClick).toHaveBeenCalledTimes(1);
for (let i = 0; i < 5; i++) {
container.querySelector('button').click();
}
expect(handleClick).toHaveBeenCalledTimes(6);
})
})
const test = (name, config) => {
const node = Horizon.render(config, container);
let event = new MouseEvent(name, {
relatedTarget: null,
bubbles: true,
screenX: 1
});
node.dispatchEvent(event);
expect(LogUtils.getAndClear()).toEqual([
`${name} capture`,
`${name} bubble`
]);
event = new MouseEvent(name, {
relatedTarget: null,
bubbles: true,
screenX: 2
});
node.dispatchEvent(event);
// 再次触发新事件
expect(LogUtils.getAndClear()).toEqual([
`${name} capture`,
`${name} bubble`
]);
}
describe('合成鼠标事件', () => {
it('onMouseMove', () => {
const onMouseMove = () => {
LogUtils.log('mousemove bubble');
};
const onMouseMoveCapture = () => {
LogUtils.log('mousemove capture');
};
test('mousemove', <div
onMouseMove={onMouseMove}
onMouseMoveCapture={onMouseMoveCapture}
/>)
});
it('onMouseDown', () => {
const onMousedown = () => {
LogUtils.log('mousedown bubble');
};
const onMousedownCapture = () => {
LogUtils.log('mousedown capture');
};
test('mousedown', <div
onMousedown={onMousedown}
onMousedownCapture={onMousedownCapture}
/>)
});
it('onMouseUp', () => {
const onMouseUp = () => {
LogUtils.log('mouseup bubble');
};
const onMouseUpCapture = () => {
LogUtils.log('mouseup capture');
};
test('mouseup', <div
onMouseUp={onMouseUp}
onMouseUpCapture={onMouseUpCapture}
/>)
});
it('onMouseOut', () => {
const onMouseOut = () => {
LogUtils.log('mouseout bubble');
};
const onMouseOutCapture = () => {
LogUtils.log('mouseout capture');
};
test('mouseout', <div
onMouseOut={onMouseOut}
onMouseOutCapture={onMouseOutCapture}
/>)
});
it('onMouseOver', () => {
const onMouseOver = () => {
LogUtils.log('mouseover bubble');
};
const onMouseOverCapture = () => {
LogUtils.log('mouseover capture');
};
test('mouseover', <div
onMouseOver={onMouseOver}
onMouseOverCapture={onMouseOverCapture}
/>)
});
})
})

View File

@ -0,0 +1,52 @@
import * as Horizon from '@cloudsop/horizon/index.ts';
import * as LogUtils from '../jest/logUtils';
describe('合成滚轮事件', () => {
it('onWheel', () => {
const realNode = Horizon.render(
<div
onWheel={event => LogUtils.log(`onWheel: ${event.type}`)}
onWheelCapture={event => LogUtils.log(`onWheelCapture: ${event.type}`)}
/>, container);
realNode.dispatchEvent(
new MouseEvent('wheel', {
bubbles: true,
cancelable: false,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'onWheelCapture: wheel',
'onWheel: wheel'
]);
});
it('可以执行preventDefault和 stopPropagation', () => {
const eventHandler = e => {
expect(e.isDefaultPrevented()).toBe(false);
e.preventDefault();
expect(e.isDefaultPrevented()).toBe(true);
expect(e.isPropagationStopped()).toBe(false);
e.stopPropagation();
expect(e.isPropagationStopped()).toBe(true);
LogUtils.log(e.type + ' handle');
};
const realNode = Horizon.render(
<div onWheel={eventHandler}/>,
container
);
realNode.dispatchEvent(
new MouseEvent('wheel', {
bubbles: true,
cancelable: true,
}),
);
expect(LogUtils.getAndClear()).toEqual([
'wheel handle'
]);
});
})

View File

@ -0,0 +1,26 @@
import { allDelegatedNativeEvents } from '../../../libs/horizon/src/event/EventCollection';
import * as LogUtils from './logUtils';
export const stopBubbleOrCapture = (e, value) => {
LogUtils.log(value)
e.stopPropagation();
};
export const getEventListeners = (dom) => {
let ret = true
let keyArray = [];
for (var key in dom) {
keyArray.push(key);
}
try {
allDelegatedNativeEvents.forEach(event => {
if (!keyArray.includes(event)) {
ret = false;
throw new Error('没有挂载全量事件');
}
})
} catch (error) {
}
return ret;
};