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..9c76b503 --- /dev/null +++ b/libs/extension/src/components/ComponentInfo.tsx @@ -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(); + 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( +
(handleCollapse(index))}> + {hasChild && } + {`${item.name}`} + {' :'} + {item.value} +
+ ); + if (isCollapsed) { + currentIndentation = indentation; + } + }); + + return ( +
+
+ {name} + + + +
+
+ {showAttr} +
+
+ ); +} + +export default function ComponentInfo({ name, attrs }: IComponentInfo) { + const { state, props, context, hooks } = attrs; + return ( +
+
+ + {name} + + + + + + + +
+
+ {context && } + {props && } + {state && } + {hooks && } +
+ rendered by +
+
+
+ ); +} \ 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..3622363e --- /dev/null +++ b/libs/extension/src/components/ComponentsInfo.less @@ -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; + } + } +} 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..ce644325 --- /dev/null +++ b/libs/extension/src/components/Search.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/libs/extension/src/components/VTree.less b/libs/extension/src/components/VTree.less new file mode 100644 index 00000000..1460ce0f --- /dev/null +++ b/libs/extension/src/components/VTree.less @@ -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%); + } +} diff --git a/libs/extension/src/components/VTree.tsx b/libs/extension/src/components/VTree.tsx new file mode 100644 index 00000000..86f1daf7 --- /dev/null +++ b/libs/extension/src/components/VTree.tsx @@ -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 ? : ''; + 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({char}); + cutName = cutName.slice(index + char.length); + showName.push(cutName); + } else { + showName = name; + } + return ( +
+
+ {showIcon} +
+ + {showName} + + {isShowKey && ( + <> + + {' '}key + + {'="'} + + {userKey} + + {'"'} + + )} +
+ ); +} + +function VTree({ data, highlightValue }: { data: IData[], highlightValue: string }) { + const [scrollTop, setScrollTop] = useState(0); + const [collapseNode, setCollapseNode] = useState(new Set()); + const [selectItem, setSelectItem] = useState(); + const changeCollapseNode = (id: string) => { + const nodes = new Set(); + 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( + + ); + } + 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 ( +
+ {showList} + {/* 确保有足够的高度 */} +
+
+ ); +} + +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..7e74bacf --- /dev/null +++ b/libs/extension/src/components/assets.less @@ -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; 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..f48e225a --- /dev/null +++ b/libs/extension/src/panel/App.less @@ -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; +} diff --git a/libs/extension/src/panel/App.tsx b/libs/extension/src/panel/App.tsx new file mode 100644 index 00000000..2b9ef6bc --- /dev/null +++ b/libs/extension/src/panel/App.tsx @@ -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 ( +
+
+
+
+ { + keyCode = e.keyCode; + }} + />, + container, + ); + node.dispatchEvent( + new KeyboardEvent('keypress', { + keyCode: 65, + bubbles: true, + cancelable: true, + }), + ); + expect(keyCode).toBe(65); + }); + + it('阻止事件冒泡', () => { + const App = () => { + return ( + <> +
LogUtils.log('div capture')} onClick={() => LogUtils.log('div bubble')}> +

LogUtils.log('p capture')} onClick={() => LogUtils.log('p bubble')}> +

+ + ); + } + Horizon.render(, container); + container.querySelector('button').click(); + + expect(LogUtils.getAndClear()).toEqual([ + // 到button时停止冒泡 + 'div capture', + 'p capture', + 'btn capture', + 'btn bubble' + ]); + }) + + it('阻止事件捕获', () => { + const App = () => { + return ( + <> +
TestUtils.stopBubbleOrCapture(e, 'div capture')} onClick={() => LogUtils.log('div bubble')}> +

LogUtils.log('p capture')} onClick={() => LogUtils.log('p bubble')}> +

+ + ); + } + Horizon.render(, container); + container.querySelector('button').click(); + + expect(LogUtils.getAndClear()).toEqual([ + // 阻止捕获,不再继续向下执行 + 'div capture' + ]); + }) + + it('阻止原生事件冒泡', () => { + const App = () => { + return ( +
+

+

+ ); + } + Horizon.render(, 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' + ]); + }) +}) diff --git a/scripts/__tests__/EventTest/FocusEvent.test.js b/scripts/__tests__/EventTest/FocusEvent.test.js new file mode 100644 index 00000000..ee5e3c46 --- /dev/null +++ b/scripts/__tests__/EventTest/FocusEvent.test.js @@ -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( + 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( + 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', + ]); + }) +}) \ No newline at end of file diff --git a/scripts/__tests__/EventTest/KeyboardEvent.test.js b/scripts/__tests__/EventTest/KeyboardEvent.test.js new file mode 100644 index 00000000..519cc9ce --- /dev/null +++ b/scripts/__tests__/EventTest/KeyboardEvent.test.js @@ -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( + { + 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( + { + 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( + { + 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( + { + 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( +
, + 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' + ]); + }); +}); diff --git a/scripts/__tests__/EventTest/MouseEvent.test.js b/scripts/__tests__/EventTest/MouseEvent.test.js new file mode 100644 index 00000000..e5ffbae1 --- /dev/null +++ b/scripts/__tests__/EventTest/MouseEvent.test.js @@ -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 ( + <> +

{this.state.num}

+

{this.state.price}

+ + + + ); + } + } + Horizon.render(, 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(, 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',
) + }); + + it('onMouseDown', () => { + const onMousedown = () => { + LogUtils.log('mousedown bubble'); + }; + const onMousedownCapture = () => { + LogUtils.log('mousedown capture'); + }; + test('mousedown',
) + }); + + it('onMouseUp', () => { + const onMouseUp = () => { + LogUtils.log('mouseup bubble'); + }; + const onMouseUpCapture = () => { + LogUtils.log('mouseup capture'); + }; + test('mouseup',
) + }); + + it('onMouseOut', () => { + const onMouseOut = () => { + LogUtils.log('mouseout bubble'); + }; + const onMouseOutCapture = () => { + LogUtils.log('mouseout capture'); + }; + test('mouseout',
) + }); + + it('onMouseOver', () => { + const onMouseOver = () => { + LogUtils.log('mouseover bubble'); + }; + const onMouseOverCapture = () => { + LogUtils.log('mouseover capture'); + }; + test('mouseover',
) + }); + }) +}) diff --git a/scripts/__tests__/EventTest/WheelEvent.test.js b/scripts/__tests__/EventTest/WheelEvent.test.js new file mode 100644 index 00000000..a0ebf933 --- /dev/null +++ b/scripts/__tests__/EventTest/WheelEvent.test.js @@ -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( +
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( +
, + container + ); + + realNode.dispatchEvent( + new MouseEvent('wheel', { + bubbles: true, + cancelable: true, + }), + ); + expect(LogUtils.getAndClear()).toEqual([ + 'wheel handle' + ]); + }); + +}) \ No newline at end of file diff --git a/scripts/__tests__/jest/testUtils.js b/scripts/__tests__/jest/testUtils.js new file mode 100644 index 00000000..8bab93d9 --- /dev/null +++ b/scripts/__tests__/jest/testUtils.js @@ -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; +}; \ No newline at end of file