From 914a0ce6e57de68156dff908a8e789edc6b1c691 Mon Sep 17 00:00:00 2001 From: * <*> Date: Tue, 16 May 2023 20:58:14 +0800 Subject: [PATCH 1/3] Match-id-b191193225d35dcd637d7ef1902db54e05f62f6f --- libs/horizon/src/dom/DOMExternal.ts | 4 +- libs/horizon/src/event/EventBinding.ts | 13 +- libs/horizon/src/event/EventHub.ts | 5 + libs/horizon/src/event/EventWrapper.ts | 3 + libs/horizon/src/event/HorizonEventMain.ts | 23 +- libs/horizon/src/event/ListenerGetter.ts | 90 ++++++ libs/horizon/src/event/MouseEvent.ts | 109 +++++++ libs/horizon/src/event/utils.ts | 26 +- libs/horizon/src/renderer/render/DomPortal.ts | 4 +- libs/horizon/src/renderer/vnode/VNodeFlags.ts | 7 + .../EventTest/MouseEnterEvent.test.js | 301 ++++++++++++++++++ 11 files changed, 571 insertions(+), 14 deletions(-) create mode 100644 libs/horizon/src/event/MouseEvent.ts create mode 100644 scripts/__tests__/EventTest/MouseEnterEvent.test.js diff --git a/libs/horizon/src/dom/DOMExternal.ts b/libs/horizon/src/dom/DOMExternal.ts index 556f5952..8a04a66a 100644 --- a/libs/horizon/src/dom/DOMExternal.ts +++ b/libs/horizon/src/dom/DOMExternal.ts @@ -18,7 +18,8 @@ import { createPortal } from '../renderer/components/CreatePortal'; import type { Container } from './DOMOperator'; import { isElement } from './utils/Common'; import { findDOMByClassInst } from '../renderer/vnode/VNodeUtils'; -import { Callback } from '../renderer/UpdateHandler'; +import {Callback} from '../renderer/UpdateHandler'; +import {listenSimulatedDelegatedEvents} from '../event/EventBinding'; function createRoot(children: any, container: Container, callback?: Callback) { // 清空容器 @@ -31,6 +32,7 @@ function createRoot(children: any, container: Container, callback?: Callback) { // 调度器创建根节点,并给容器dom赋vNode结构体 const treeRoot = createTreeRootVNode(container); container._treeRoot = treeRoot; + listenSimulatedDelegatedEvents(treeRoot); // 执行回调 if (typeof callback === 'function') { diff --git a/libs/horizon/src/event/EventBinding.ts b/libs/horizon/src/event/EventBinding.ts index fd7a4d0c..ddfb016c 100644 --- a/libs/horizon/src/event/EventBinding.ts +++ b/libs/horizon/src/event/EventBinding.ts @@ -16,16 +16,14 @@ /** * 事件绑定实现,分为绑定委托事件和非委托事件 */ -import { allDelegatedHorizonEvents, allDelegatedNativeEvents } from './EventHub'; -import { isDocument } from '../dom/utils/Common'; +import {allDelegatedHorizonEvents, simulatedDelegatedEvents} from './EventHub'; +import {isDocument} from '../dom/utils/Common'; import { getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys'; import { asyncUpdates, runDiscreteUpdates } from '../renderer/TreeBuilder'; import { handleEventMain } from './HorizonEventMain'; import { decorateNativeEvent } from './EventWrapper'; import { VNode } from '../renderer/vnode/VNode'; -const listeningMarker = '_horizonListening' + Math.random().toString(36).slice(4); - // 触发委托事件 function triggerDelegatedEvent( nativeEvtName: string, @@ -64,6 +62,13 @@ function isCaptureEvent(horizonEventName) { return horizonEventName.slice(-7) === 'Capture'; } +// 利用冒泡事件模拟不冒泡事件,需要直接在根节点绑定 +export function listenSimulatedDelegatedEvents(root: VNode) { + for (let i = 0; i < simulatedDelegatedEvents.length; i++) { + lazyDelegateOnRoot(root, simulatedDelegatedEvents[i]); + } +} + // 事件懒委托,当用户定义事件后,再进行委托到根节点 export function lazyDelegateOnRoot(currentRoot: VNode, eventName: string) { currentRoot.delegatedEvents.add(eventName); diff --git a/libs/horizon/src/event/EventHub.ts b/libs/horizon/src/event/EventHub.ts index 35ad13ac..e1a80969 100644 --- a/libs/horizon/src/event/EventHub.ts +++ b/libs/horizon/src/event/EventHub.ts @@ -15,6 +15,9 @@ // 需要委托的horizon事件和原生事件对应关系 export const allDelegatedHorizonEvents = new Map(); + +// 模拟委托事件,事件本事不冒泡,需要其他事件来触发冒泡过程 +export const simulatedDelegatedEvents = ['onMouseEnter', 'onMouseLeave']; // 所有委托的原生事件集合 export const allDelegatedNativeEvents = new Set(); @@ -49,6 +52,8 @@ export const horizonEventToNativeMap = new Map([ ['onCompositionUpdate', ['compositionupdate']], ['onChange', ['change', 'click', 'focusout', 'input']], ['onSelect', ['select']], + ['onMouseEnter', ['mouseout', 'mouseover']], + ['onMouseLeave', ['mouseout', 'mouseover']], ['onAnimationEnd', ['animationend']], ['onAnimationIteration', ['animationiteration']], diff --git a/libs/horizon/src/event/EventWrapper.ts b/libs/horizon/src/event/EventWrapper.ts index 6ed15a19..98906694 100644 --- a/libs/horizon/src/event/EventWrapper.ts +++ b/libs/horizon/src/event/EventWrapper.ts @@ -37,6 +37,9 @@ export class WrappedEvent { key: string; currentTarget: EventTarget | null = null; + target: HTMLElement; + relatedTarget: HTMLElement; + stopPropagation: () => void; preventDefault: () => void; diff --git a/libs/horizon/src/event/HorizonEventMain.ts b/libs/horizon/src/event/HorizonEventMain.ts index 5c0ae747..ad0e8326 100644 --- a/libs/horizon/src/event/HorizonEventMain.ts +++ b/libs/horizon/src/event/HorizonEventMain.ts @@ -30,7 +30,8 @@ import { import { getDomTag } from '../dom/utils/Common'; import { updateInputHandlerIfChanged } from '../dom/valueHandler/ValueChangeHandler'; import { getDom } from '../dom/DOMInternalKeys'; -import { recordChangeEventTargets, shouldControlValue, tryControlValue } from './FormValueController'; +import {recordChangeEventTargets, shouldControlValue, tryControlValue} from './FormValueController'; +import {getMouseEnterListeners} from './MouseEvent'; // web规范,鼠标右键key值 const RIGHT_MOUSE_BUTTON = 2; @@ -141,18 +142,26 @@ function triggerHorizonEvents( const target = nativeEvent.target || nativeEvent.srcElement!; // 触发普通委托事件 - let listenerList: ListenerUnitList = getCommonListeners(nativeEvtName, vNode, nativeEvent, target, isCapture); + const listenerList: ListenerUnitList = getCommonListeners(nativeEvtName, vNode, nativeEvent, target, isCapture); + let mouseEnterListeners: ListenerUnitList = []; + if (horizonEventToNativeMap.get('onMouseEnter')!.includes(nativeEvtName)) { + mouseEnterListeners = getMouseEnterListeners( + nativeEvtName, + vNode, + nativeEvent, + target, + ); + } + + let changeEvents: ListenerUnitList = []; // 触发特殊handler委托事件 if (!isCapture && horizonEventToNativeMap.get('onChange')!.includes(nativeEvtName)) { - const changeListeners = getChangeListeners(nativeEvtName, nativeEvent, vNode, target); - if (changeListeners.length) { - listenerList = listenerList.concat(changeListeners); - } + changeEvents = getChangeListeners(nativeEvtName, nativeEvent, vNode, target); } // 处理触发的事件队列 - processListeners(listenerList); + processListeners([...listenerList, ...mouseEnterListeners, ...changeEvents]); } // 其他事件正在执行中标记 diff --git a/libs/horizon/src/event/ListenerGetter.ts b/libs/horizon/src/event/ListenerGetter.ts index efd363fb..cda9c3e1 100644 --- a/libs/horizon/src/event/ListenerGetter.ts +++ b/libs/horizon/src/event/ListenerGetter.ts @@ -86,3 +86,93 @@ export function getListenersFromTree( return listeners; } + +// 获取enter和leave事件队列 +export function collectMouseListeners( + leaveEvent: null | WrappedEvent, + enterEvent: null | WrappedEvent, + from: VNode | null, + to: VNode | null, +): ListenerUnitList { + // 确定公共父节点,作为在树上遍历的终点 + const commonParent = from && to ? getCommonAncestor(from, to) : null; + let leaveEventList: ListenerUnitList = []; + if (from && leaveEvent) { + // 遍历树,获取绑定的leave事件 + leaveEventList = getMouseListenersFromTree( + leaveEvent, + from, + commonParent, + ); + } + let enterEventList: ListenerUnitList = []; + if (to && enterEvent) { + // 先触发父节点enter事件,所以需要逆序 + enterEventList = getMouseListenersFromTree( + enterEvent, + to, + commonParent, + ).reverse(); + } + return [...leaveEventList, ...enterEventList]; +} + +function getMouseListenersFromTree( + event: WrappedEvent, + target: VNode, + commonParent: VNode | null, +): ListenerUnitList { + const registrationName = event.customEventName; + const listeners: ListenerUnitList = []; + + let vNode = target; + while (vNode !== null) { + // commonParent作为终点 + if (vNode === commonParent) { + break; + } + const {realNode, tag} = vNode; + if (tag === DomComponent && realNode !== null) { + const currentTarget = realNode; + const listener = getListenerFromVNode(vNode, registrationName); + if (listener) { + listeners.push({ + vNode, + listener, + currentTarget, + event, + }); + } + } + vNode = vNode.parent; + } + return listeners; +} + +// 寻找两个节点的共同最近祖先,如果没有则返回null +function getCommonAncestor(instA: VNode, instB: VNode): VNode | null { + const parentsSet = new Set(); + for (let tempA: VNode | null = instA; tempA; tempA = getParent(tempA)) { + parentsSet.add(tempA); + } + for (let tempB: VNode | null = instB; tempB; tempB = getParent(tempB)) { + if (parentsSet.has(tempB)) { + return tempB; + } + } + return null; +} + +// 获取父节点 +function getParent(inst: VNode | null): VNode | null { + if (inst === null) { + return null; + } + do { + inst = inst.parent; + } while (inst && inst.tag !== DomComponent); + if (inst) { + return inst; + } + return null; +} diff --git a/libs/horizon/src/event/MouseEvent.ts b/libs/horizon/src/event/MouseEvent.ts new file mode 100644 index 00000000..e947a98f --- /dev/null +++ b/libs/horizon/src/event/MouseEvent.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * + * openGauss 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 {getNearestVNode} from '../dom/DOMInternalKeys'; +import {WrappedEvent} from './EventWrapper'; +import {VNode} from '../renderer/vnode/VNode'; +import {AnyNativeEvent, ListenerUnitList} from './Types'; +import {DomComponent, DomText} from '../renderer/vnode/VNodeTags'; +import {collectMouseListeners} from './ListenerGetter'; +import {getNearestMountedVNode} from './utils'; + +/** + * 背景: mouseEnter和mouseLeave事件不冒泡,所以无法直接委托给根节点进行代理 + * 实现方案:利用mouseout、mouseover事件的,找到事件触发的起点和终点,判断出鼠标移动轨迹,在轨迹中的节点触发mouseEnter和mouseLeave事件 + * 步骤: + * 1. 根节点绑定mouseout和mouseover事件 + * 2. 事件触发后找到事件的起点和终点 + * 3. 封装装enter和leave事件 + * 4. 根据起止点找到公共父节点,作为事件冒泡的终点 + * 5. 遍历treeNode,找到每个节点绑定的mouseEnter和mouseLeave监听方法 + * 例如: mouseOut事件由D->C, A节点作为公共父节点,将触发 D、B的mouseLeave事件和C节点的mouseEnter事件 + * A + * / \ + * B C + * / \ + * D E + * + */ + +function getWrapperEvents(nativeEventTarget, fromInst, toInst, nativeEvent, targetInst): (WrappedEvent | null)[] { + const vWindow = nativeEventTarget.window === nativeEventTarget ? nativeEventTarget : nativeEventTarget.ownerDocument.defaultView; + + // 起点或者终点为空的话默认值为所在window + const fromNode = fromInst?.realNode || vWindow; + const toNode = toInst?.realNode || vWindow; + let leave: WrappedEvent | null = null; + let enter: WrappedEvent | null = null; + const nativeTargetInst = getNearestVNode(nativeEventTarget); + + // 在Mounted的dom节点上render一个子组件,系统中存在两个根节点,子节点的mouseout事件触发两次,取离target近的根节点生效 + if (nativeTargetInst === targetInst) { + leave = new WrappedEvent('onMouseLeave', 'mouseleave', nativeEvent); + leave.target = fromNode; + leave.relatedTarget = toNode; + + enter = new WrappedEvent('onMouseEnter', 'mouseenter', nativeEvent); + enter.target = toNode; + enter.relatedTarget = fromNode; + } + return [leave, enter]; +} + +function getEndpointVNode( + domEventName: string, + targetInst: null | VNode, + nativeEvent: AnyNativeEvent, +): (VNode | null)[] { + let fromVNode; + let toVNode; + if (domEventName === 'mouseover') { + fromVNode = null; + toVNode = targetInst; + } else { + const related = nativeEvent.relatedTarget || nativeEvent.toElement; + fromVNode = targetInst; + toVNode = related ? getNearestVNode(related) : null; + if (toVNode !== null) { + const nearestMounted = getNearestMountedVNode(toVNode); + if (toVNode !== nearestMounted || (toVNode.tag !== DomComponent && toVNode.tag !== DomText)) { + toVNode = null; + } + } + } + return [fromVNode, toVNode]; +} + +export function getMouseEnterListeners( + domEventName: string, + targetInst: null | VNode, + nativeEvent: AnyNativeEvent, + nativeEventTarget: null | EventTarget, +): ListenerUnitList { + + // 获取起点和终点的VNode + const [fromVNode, toVNode] = getEndpointVNode(domEventName, targetInst, nativeEvent); + if (fromVNode === toVNode) { + return []; + } + + // 获取包装后的leave和enter事件 + const [leave, enter] = getWrapperEvents(nativeEventTarget, fromVNode, toVNode, nativeEvent, targetInst); + + // 收集事件的监听方法 + return collectMouseListeners(leave, enter, fromVNode, toVNode); +} + + diff --git a/libs/horizon/src/event/utils.ts b/libs/horizon/src/event/utils.ts index 6f21e52e..484d98b4 100644 --- a/libs/horizon/src/event/utils.ts +++ b/libs/horizon/src/event/utils.ts @@ -13,6 +13,10 @@ * See the Mulan PSL v2 for more details. */ +import {VNode} from '../renderer/vnode/VNode'; +import {Addition, FlagUtils} from '../renderer/vnode/VNodeFlags'; +import {TreeRoot} from '../renderer/vnode/VNodeTags'; + export function isInputElement(dom?: HTMLElement): boolean { return dom instanceof HTMLInputElement || dom instanceof HTMLTextAreaElement; } @@ -20,6 +24,26 @@ export function isInputElement(dom?: HTMLElement): boolean { export function setPropertyWritable(obj, propName) { const desc = Object.getOwnPropertyDescriptor(obj, propName); if (!desc || !desc.writable) { - Object.defineProperty(obj, propName, { writable: true }); + Object.defineProperty(obj, propName, {writable: true}); } } + +// 获取离 vNode 最近的已挂载 vNode,包含它自己 +export function getNearestMountedVNode(vNode: VNode): null | VNode { + let node = vNode; + let target = vNode; + // 如果没有alternate,说明是可能是未插入的新树,需要处理插入的副作用。 + while (node.parent) { + // 存在更新,节点未挂载,查找父节点,但是父节点也可能未挂载,需要继续往上查找无更新节点 + if (FlagUtils.hasFlag(node, Addition)) { + target = node.parent; + } + node = node.parent; + } + // 如果根节点是 Dom 类型节点,表示已经挂载 + if (node.tag === TreeRoot) { + return target; + } + // 如果没有找到根节点,意味着Tree已经卸载或者未挂载 + return null; +} diff --git a/libs/horizon/src/renderer/render/DomPortal.ts b/libs/horizon/src/renderer/render/DomPortal.ts index 4ef690fe..65188f0a 100644 --- a/libs/horizon/src/renderer/render/DomPortal.ts +++ b/libs/horizon/src/renderer/render/DomPortal.ts @@ -16,10 +16,12 @@ import type { VNode } from '../Types'; import { resetNamespaceCtx, setNamespaceCtx } from '../ContextSaver'; import { createChildrenByDiff } from '../diff/nodeDiffComparator'; -import { popCurrentRoot, pushCurrentRoot } from '../RootStack'; +import {popCurrentRoot, pushCurrentRoot} from '../RootStack'; +import {listenSimulatedDelegatedEvents} from '../../event/EventBinding'; export function bubbleRender(processing: VNode) { resetNamespaceCtx(processing); + listenSimulatedDelegatedEvents(processing); popCurrentRoot(); } diff --git a/libs/horizon/src/renderer/vnode/VNodeFlags.ts b/libs/horizon/src/renderer/vnode/VNodeFlags.ts index d1971106..31e40aa7 100644 --- a/libs/horizon/src/renderer/vnode/VNodeFlags.ts +++ b/libs/horizon/src/renderer/vnode/VNodeFlags.ts @@ -40,14 +40,20 @@ export class FlagUtils { static removeFlag(node: VNode, flag: number) { node.flags &= ~flag; } + static removeLifecycleEffectFlags(node) { node.flags &= ~LifecycleEffectArr; } + static hasAnyFlag(node: VNode) { // 有标志位 return node.flags !== InitFlag; } + static hasFlag(node: VNode, flag) { + return (node.flags & flag) !== 0; + } + static setNoFlags(node: VNode) { node.flags = InitFlag; } @@ -55,6 +61,7 @@ export class FlagUtils { static markAddition(node: VNode) { node.flags |= Addition; } + static setAddition(node: VNode) { node.flags = Addition; } diff --git a/scripts/__tests__/EventTest/MouseEnterEvent.test.js b/scripts/__tests__/EventTest/MouseEnterEvent.test.js new file mode 100644 index 00000000..e0f831c8 --- /dev/null +++ b/scripts/__tests__/EventTest/MouseEnterEvent.test.js @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * + * openGauss 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 * as Horizon from '@cloudsop/horizon/index.ts'; +import * as Renderer from '../../../libs/horizon/src/renderer/Renderer'; +import { doc } from 'prettier'; + +describe('EnterLeaveEventPlugin', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + // The container has to be attached for events to fire. + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + it('should set onMouseLeave relatedTarget properly in iframe', () => { + const iframe = document.createElement('iframe'); + container.appendChild(iframe); + const iframeDocument = iframe.contentDocument; + iframeDocument.write( + '
', + ); + iframeDocument.close(); + + const leaveEvents = []; + const node = Horizon.render( +
{ + e.persist(); + leaveEvents.push(e); + }} + />, + iframeDocument.body.getElementsByTagName('div')[0], + ); + + node.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: iframe.contentWindow, + }), + ); + + expect(leaveEvents.length).toBe(1); + expect(leaveEvents[0].target).toBe(node); + expect(leaveEvents[0].relatedTarget).toBe(iframe.contentWindow); + }); + + it('should set onMouseEnter relatedTarget properly in iframe', () => { + const iframe = document.createElement('iframe'); + container.appendChild(iframe); + const iframeDocument = iframe.contentDocument; + iframeDocument.write( + '
', + ); + iframeDocument.close(); + + const enterEvents = []; + const node = Horizon.render( +
{ + e.persist(); + enterEvents.push(e); + }} + />, + iframeDocument.body.getElementsByTagName('div')[0], + ); + + node.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: null, + }), + ); + + expect(enterEvents.length).toBe(1); + expect(enterEvents[0].target).toBe(node); + expect(enterEvents[0].relatedTarget).toBe(iframe.contentWindow); + }); + + // Regression test for https://github.com/facebook/Horizon/issues/10906. + it('should find the common parent after updates', () => { + let parentEnterCalls = 0; + let childEnterCalls = 0; + let parent = null; + + class Parent extends Horizon.Component { + render() { + return ( +
parentEnterCalls++} + ref={node => (parent = node)}> + {this.props.showChild && ( +
childEnterCalls++}/> + )} +
+ ); + } + } + + Horizon.render(, container); + // The issue only reproduced on insertion during the first update. + Horizon.render(, container); + + // Enter from parent into the child. + parent.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: parent.firstChild, + }), + ); + + // Entering a child should fire on the child, not on the parent. + expect(childEnterCalls).toBe(1); + expect(parentEnterCalls).toBe(0); + }); + + it('should call mouseEnter once from sibling rendered inside a rendered component', done => { + const mockFn1 = jest.fn(); + const mockFn2 = jest.fn(); + const mockFn3 = jest.fn(); + + class Parent extends Horizon.Component { + constructor(props) { + super(props); + this.parentEl = Horizon.createRef(); + } + + componentDidMount() { + Horizon.render(, this.parentEl.current); + } + + render() { + return
; + } + } + + class MouseEnterDetect extends Horizon.Component { + constructor(props) { + super(props); + this.firstEl = Horizon.createRef(); + this.siblingEl = Horizon.createRef(); + } + + componentDidMount() { + this.siblingEl.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: this.firstEl.current, + }), + ); + expect(mockFn1.mock.calls.length).toBe(1); + expect(mockFn2.mock.calls.length).toBe(1); + expect(mockFn3.mock.calls.length).toBe(0); + done(); + } + + render() { + return ( + +
+
+ + ); + } + } + + Horizon.render(, container); + }); + + it('should call mouseEnter when pressing a non tracked Horizon node', done => { + const mockFn = jest.fn(); + + class Parent extends Horizon.Component { + constructor(props) { + super(props); + this.parentEl = Horizon.createRef(); + } + + componentDidMount() { + Horizon.render(, this.parentEl.current); + } + + render() { + return
; + } + } + + class MouseEnterDetect extends Horizon.Component { + constructor(props) { + super(props); + this.divRef = Horizon.createRef(); + this.siblingEl = Horizon.createRef(); + } + + componentDidMount() { + const attachedNode = document.createElement('div'); + this.divRef.current.appendChild(attachedNode); + attachedNode.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: this.siblingEl.current, + }), + ); + expect(mockFn.mock.calls.length).toBe(1); + done(); + } + + render() { + return ( +
+
+
+ ); + } + } + + Horizon.render(, container); + }); + + it('should work with portals outside of the root that has onMouseLeave', () => { + const divRef = Horizon.createRef(); + const onMouseLeave = jest.fn(); + + function Component() { + return ( +
+ {Horizon.createPortal(
, document.body)} +
+ ); + } + + Horizon.render(, container); + + // Leave from the portal div + divRef.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: document.body, + }), + ); + + expect(onMouseLeave).toHaveBeenCalledTimes(1); + }); + + it('should work with portals that have onMouseEnter outside of the root ', () => { + const divRef = Horizon.createRef(); + const otherDivRef = Horizon.createRef(); + const onMouseEnter = jest.fn(); + + function Component() { + return ( +
+ {Horizon.createPortal( +
, + document.body, + )} +
+ ); + } + + Horizon.render(, container); + + // Leave from the portal div + divRef.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: otherDivRef.current, + }), + ); + + expect(onMouseEnter).toHaveBeenCalledTimes(1); + }); +}); + + From 43023ca26afb48263eae3bf78ce0913aca1fdfe9 Mon Sep 17 00:00:00 2001 From: * <*> Date: Thu, 25 May 2023 09:40:45 +0800 Subject: [PATCH 2/3] Match-id-315cebc9891bb15a1e6cf5d419fd71f880796f3a --- libs/horizon/src/dom/DOMExternal.ts | 4 ++-- libs/horizon/src/event/EventBinding.ts | 4 ++-- libs/horizon/src/event/EventHub.ts | 2 +- libs/horizon/src/event/HorizonEventMain.ts | 4 ++-- libs/horizon/src/event/ListenerGetter.ts | 5 +---- libs/horizon/src/event/MouseEvent.ts | 14 +++++++------- libs/horizon/src/event/utils.ts | 6 +++--- libs/horizon/src/renderer/render/DomPortal.ts | 4 ++-- 8 files changed, 20 insertions(+), 23 deletions(-) diff --git a/libs/horizon/src/dom/DOMExternal.ts b/libs/horizon/src/dom/DOMExternal.ts index 8a04a66a..dbd78da4 100644 --- a/libs/horizon/src/dom/DOMExternal.ts +++ b/libs/horizon/src/dom/DOMExternal.ts @@ -18,8 +18,8 @@ import { createPortal } from '../renderer/components/CreatePortal'; import type { Container } from './DOMOperator'; import { isElement } from './utils/Common'; import { findDOMByClassInst } from '../renderer/vnode/VNodeUtils'; -import {Callback} from '../renderer/UpdateHandler'; -import {listenSimulatedDelegatedEvents} from '../event/EventBinding'; +import { Callback } from '../renderer/UpdateHandler'; +import { listenSimulatedDelegatedEvents } from '../event/EventBinding'; function createRoot(children: any, container: Container, callback?: Callback) { // 清空容器 diff --git a/libs/horizon/src/event/EventBinding.ts b/libs/horizon/src/event/EventBinding.ts index ddfb016c..e4e0f59d 100644 --- a/libs/horizon/src/event/EventBinding.ts +++ b/libs/horizon/src/event/EventBinding.ts @@ -16,8 +16,8 @@ /** * 事件绑定实现,分为绑定委托事件和非委托事件 */ -import {allDelegatedHorizonEvents, simulatedDelegatedEvents} from './EventHub'; -import {isDocument} from '../dom/utils/Common'; +import { allDelegatedHorizonEvents, simulatedDelegatedEvents } from './EventHub'; +import { isDocument } from '../dom/utils/Common'; import { getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys'; import { asyncUpdates, runDiscreteUpdates } from '../renderer/TreeBuilder'; import { handleEventMain } from './HorizonEventMain'; diff --git a/libs/horizon/src/event/EventHub.ts b/libs/horizon/src/event/EventHub.ts index e1a80969..51d9ced6 100644 --- a/libs/horizon/src/event/EventHub.ts +++ b/libs/horizon/src/event/EventHub.ts @@ -16,7 +16,7 @@ // 需要委托的horizon事件和原生事件对应关系 export const allDelegatedHorizonEvents = new Map(); -// 模拟委托事件,事件本事不冒泡,需要其他事件来触发冒泡过程 +// 模拟委托事件,不冒泡事件需要利用其他事件来触发冒泡过程 export const simulatedDelegatedEvents = ['onMouseEnter', 'onMouseLeave']; // 所有委托的原生事件集合 export const allDelegatedNativeEvents = new Set(); diff --git a/libs/horizon/src/event/HorizonEventMain.ts b/libs/horizon/src/event/HorizonEventMain.ts index ad0e8326..a2a20d5e 100644 --- a/libs/horizon/src/event/HorizonEventMain.ts +++ b/libs/horizon/src/event/HorizonEventMain.ts @@ -30,8 +30,8 @@ import { import { getDomTag } from '../dom/utils/Common'; import { updateInputHandlerIfChanged } from '../dom/valueHandler/ValueChangeHandler'; import { getDom } from '../dom/DOMInternalKeys'; -import {recordChangeEventTargets, shouldControlValue, tryControlValue} from './FormValueController'; -import {getMouseEnterListeners} from './MouseEvent'; +import { recordChangeEventTargets, shouldControlValue, tryControlValue } from './FormValueController'; +import { getMouseEnterListeners } from './MouseEvent'; // web规范,鼠标右键key值 const RIGHT_MOUSE_BUTTON = 2; diff --git a/libs/horizon/src/event/ListenerGetter.ts b/libs/horizon/src/event/ListenerGetter.ts index cda9c3e1..3b19cf2a 100644 --- a/libs/horizon/src/event/ListenerGetter.ts +++ b/libs/horizon/src/event/ListenerGetter.ts @@ -171,8 +171,5 @@ function getParent(inst: VNode | null): VNode | null { do { inst = inst.parent; } while (inst && inst.tag !== DomComponent); - if (inst) { - return inst; - } - return null; + return inst || null; } diff --git a/libs/horizon/src/event/MouseEvent.ts b/libs/horizon/src/event/MouseEvent.ts index e947a98f..3eb38123 100644 --- a/libs/horizon/src/event/MouseEvent.ts +++ b/libs/horizon/src/event/MouseEvent.ts @@ -13,13 +13,13 @@ * See the Mulan PSL v2 for more details. */ -import {getNearestVNode} from '../dom/DOMInternalKeys'; -import {WrappedEvent} from './EventWrapper'; -import {VNode} from '../renderer/vnode/VNode'; -import {AnyNativeEvent, ListenerUnitList} from './Types'; -import {DomComponent, DomText} from '../renderer/vnode/VNodeTags'; -import {collectMouseListeners} from './ListenerGetter'; -import {getNearestMountedVNode} from './utils'; +import { getNearestVNode } from '../dom/DOMInternalKeys'; +import { WrappedEvent } from './EventWrapper'; +import { VNode } from '../renderer/vnode/VNode'; +import { AnyNativeEvent, ListenerUnitList } from './Types'; +import { DomComponent, DomText } from '../renderer/vnode/VNodeTags'; +import { collectMouseListeners } from './ListenerGetter'; +import { getNearestMountedVNode } from './utils'; /** * 背景: mouseEnter和mouseLeave事件不冒泡,所以无法直接委托给根节点进行代理 diff --git a/libs/horizon/src/event/utils.ts b/libs/horizon/src/event/utils.ts index 484d98b4..46feae8a 100644 --- a/libs/horizon/src/event/utils.ts +++ b/libs/horizon/src/event/utils.ts @@ -13,9 +13,9 @@ * See the Mulan PSL v2 for more details. */ -import {VNode} from '../renderer/vnode/VNode'; -import {Addition, FlagUtils} from '../renderer/vnode/VNodeFlags'; -import {TreeRoot} from '../renderer/vnode/VNodeTags'; +import { VNode } from '../renderer/vnode/VNode'; +import { Addition, FlagUtils } from '../renderer/vnode/VNodeFlags'; +import { TreeRoot } from '../renderer/vnode/VNodeTags'; export function isInputElement(dom?: HTMLElement): boolean { return dom instanceof HTMLInputElement || dom instanceof HTMLTextAreaElement; diff --git a/libs/horizon/src/renderer/render/DomPortal.ts b/libs/horizon/src/renderer/render/DomPortal.ts index 65188f0a..df0e3e30 100644 --- a/libs/horizon/src/renderer/render/DomPortal.ts +++ b/libs/horizon/src/renderer/render/DomPortal.ts @@ -16,8 +16,8 @@ import type { VNode } from '../Types'; import { resetNamespaceCtx, setNamespaceCtx } from '../ContextSaver'; import { createChildrenByDiff } from '../diff/nodeDiffComparator'; -import {popCurrentRoot, pushCurrentRoot} from '../RootStack'; -import {listenSimulatedDelegatedEvents} from '../../event/EventBinding'; +import { popCurrentRoot, pushCurrentRoot } from '../RootStack'; +import { listenSimulatedDelegatedEvents } from '../../event/EventBinding'; export function bubbleRender(processing: VNode) { resetNamespaceCtx(processing); From 60cf2e32ce7642376bfbdb286c21d81a33c3e743 Mon Sep 17 00:00:00 2001 From: * <*> Date: Mon, 29 May 2023 14:59:17 +0800 Subject: [PATCH 3/3] Match-id-785473ead33f3451a1dddc93c6cea6efae7c6563 --- CHANGELOG.md | 3 +++ libs/horizon/package.json | 2 +- package.json | 3 +++ .../EventTest/MouseEnterEvent.test.js | 26 ++++++------------- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9010423..a49e9d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.0.51 (2023-05-29) +- **core**: 增加mouseenter和mouseleave事件代理机制 + ## 0.0.50 (2023-05-23) - **core**: 解决IE11不兼容Symbol问题 diff --git a/libs/horizon/package.json b/libs/horizon/package.json index 286222ac..f2ed85ce 100644 --- a/libs/horizon/package.json +++ b/libs/horizon/package.json @@ -4,7 +4,7 @@ "keywords": [ "horizon" ], - "version": "0.0.50", + "version": "0.0.51", "homepage": "", "bugs": "", "main": "index.js", diff --git a/package.json b/package.json index d122cacb..94d6ac45 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,7 @@ { + "name": "@cloudsop/horizon", + "description": "Horizon is a JavaScript framework library.", + "version": "0.0.51", "private": true, "workspaces": [ "libs/*" diff --git a/scripts/__tests__/EventTest/MouseEnterEvent.test.js b/scripts/__tests__/EventTest/MouseEnterEvent.test.js index e0f831c8..6256062f 100644 --- a/scripts/__tests__/EventTest/MouseEnterEvent.test.js +++ b/scripts/__tests__/EventTest/MouseEnterEvent.test.js @@ -14,15 +14,12 @@ */ import * as Horizon from '@cloudsop/horizon/index.ts'; -import * as Renderer from '../../../libs/horizon/src/renderer/Renderer'; -import { doc } from 'prettier'; -describe('EnterLeaveEventPlugin', () => { +describe('mouseenter和mouseleave事件测试', () => { let container; beforeEach(() => { jest.resetModules(); - // The container has to be attached for events to fire. container = document.createElement('div'); document.body.appendChild(container); }); @@ -32,7 +29,7 @@ describe('EnterLeaveEventPlugin', () => { container = null; }); - it('should set onMouseLeave relatedTarget properly in iframe', () => { + it('在iframe中mouseleave事件的relateTarget属性', () => { const iframe = document.createElement('iframe'); container.appendChild(iframe); const iframeDocument = iframe.contentDocument; @@ -65,7 +62,7 @@ describe('EnterLeaveEventPlugin', () => { expect(leaveEvents[0].relatedTarget).toBe(iframe.contentWindow); }); - it('should set onMouseEnter relatedTarget properly in iframe', () => { + it('在iframe中mouseenter事件的relateTarget属性', () => { const iframe = document.createElement('iframe'); container.appendChild(iframe); const iframeDocument = iframe.contentDocument; @@ -98,8 +95,7 @@ describe('EnterLeaveEventPlugin', () => { expect(enterEvents[0].relatedTarget).toBe(iframe.contentWindow); }); - // Regression test for https://github.com/facebook/Horizon/issues/10906. - it('should find the common parent after updates', () => { + it('从新渲染的子组件触发mouseout事件,子组件响应mouseenter事件,父节点不响应', () => { let parentEnterCalls = 0; let childEnterCalls = 0; let parent = null; @@ -119,10 +115,8 @@ describe('EnterLeaveEventPlugin', () => { } Horizon.render(, container); - // The issue only reproduced on insertion during the first update. Horizon.render(, container); - // Enter from parent into the child. parent.dispatchEvent( new MouseEvent('mouseout', { bubbles: true, @@ -130,13 +124,11 @@ describe('EnterLeaveEventPlugin', () => { relatedTarget: parent.firstChild, }), ); - - // Entering a child should fire on the child, not on the parent. expect(childEnterCalls).toBe(1); expect(parentEnterCalls).toBe(0); }); - it('should call mouseEnter once from sibling rendered inside a rendered component', done => { + it('render一个新组件,兄弟节点触发mouseout事件,mouseenter事件响应一次', done => { const mockFn1 = jest.fn(); const mockFn2 = jest.fn(); const mockFn3 = jest.fn(); @@ -190,7 +182,7 @@ describe('EnterLeaveEventPlugin', () => { Horizon.render(, container); }); - it('should call mouseEnter when pressing a non tracked Horizon node', done => { + it('未被horizon管理的节点触发mouseout事件,mouseenter事件也能正常触发', done => { const mockFn = jest.fn(); class Parent extends Horizon.Component { @@ -241,7 +233,7 @@ describe('EnterLeaveEventPlugin', () => { Horizon.render(, container); }); - it('should work with portals outside of the root that has onMouseLeave', () => { + it('外部portal节点触发的mouseout事件,根节点的mouseleave事件也能响应', () => { const divRef = Horizon.createRef(); const onMouseLeave = jest.fn(); @@ -255,7 +247,6 @@ describe('EnterLeaveEventPlugin', () => { Horizon.render(, container); - // Leave from the portal div divRef.current.dispatchEvent( new MouseEvent('mouseout', { bubbles: true, @@ -267,7 +258,7 @@ describe('EnterLeaveEventPlugin', () => { expect(onMouseLeave).toHaveBeenCalledTimes(1); }); - it('should work with portals that have onMouseEnter outside of the root ', () => { + it('外部portal节点触发的mouseout事件,根节点的mouseEnter事件也能响应', () => { const divRef = Horizon.createRef(); const otherDivRef = Horizon.createRef(); const onMouseEnter = jest.fn(); @@ -285,7 +276,6 @@ describe('EnterLeaveEventPlugin', () => { Horizon.render(, container); - // Leave from the portal div divRef.current.dispatchEvent( new MouseEvent('mouseout', { bubbles: true,