diff --git a/docs-vitepress/guide/rn/application-api.md b/docs-vitepress/guide/rn/application-api.md
index ba225bbfa8..42ac1e26ac 100644
--- a/docs-vitepress/guide/rn/application-api.md
+++ b/docs-vitepress/guide/rn/application-api.md
@@ -477,6 +477,28 @@ Mpx 框架默认会使用 `ReactNative.AppState.addEventListener('change', callb
在需要将 RN 应用嵌入到现有的 NA 应用中时,可能会出现AppState触发时机异常的情况(例如从 RN 页面跳转到 NA 页面时),此时可以将 disableAppStateListener 设置为 true 来禁用框架内部对 AppState 的监听。但需要在合适的时机手动调用 setAppShow() 与 setAppHide() 方法来进行驱动以确保对应的钩子能正常触发。
+### page-container {#page-container}
+
+#### mpx.config.rnConfig.disableSwipeBack
+
+```ts
+(options: { disable: boolean }) => void
+```
+
+禁用或启用 iOS 页面左滑手势返回,主要用于 DRN(混合容器)等场景。
+
+当页面内使用 `page-container` 组件时,框架会在容器显示时自动调用此方法禁用手势返回,以防止用户左滑时触发页面返回而非关闭容器;容器关闭或组件销毁时会再次调用以恢复。
+
+在纯 RN 环境下,框架通过 React Navigation 的 `gestureEnabled` 选项自动处理手势返回的禁用与恢复,无需配置此项。
+
+```javascript
+// 示例(DRN 场景)
+mpx.config.rnConfig.disableSwipeBack = ({ disable }) => {
+ // 调用 NA 提供的方法控制手势返回
+ NativeModules.NavigationModule.setSwipeBackEnabled(!disable)
+}
+```
+
### 自定义设置底部虚拟按键区高度 {#custom-bottom-bar-height}
#### mpx.config.rnConfig.getBottomVirtualHeight
diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md
index 9f726bfe32..62e7895f77 100644
--- a/docs-vitepress/guide/rn/component.md
+++ b/docs-vitepress/guide/rn/component.md
@@ -5,7 +5,7 @@
### 目录概览 {#directory-overview}
- #### 基础组件
-**容器组件**:[view](#view) · [scroll-view](#scroll-view) · [swiper](#swiper) · [swiper-item](#swiper-item) · [movable-area](#movable-area) · [movable-view](#movable-view) · [root-portal](#root-portal) · [sticky-section](#sticky-section) · [sticky-header](#sticky-header) · [cover-view](#cover-view)
+**容器组件**:[view](#view) · [scroll-view](#scroll-view) · [swiper](#swiper) · [swiper-item](#swiper-item) · [movable-area](#movable-area) · [movable-view](#movable-view) · [page-container](#page-container) · [root-portal](#root-portal) · [sticky-section](#sticky-section) · [sticky-header](#sticky-header) · [cover-view](#cover-view)
**媒体组件**:[image](#image) · [video](#video) · [canvas](#canvas)
@@ -768,6 +768,80 @@ API
视图容器。
功能同 [image 组件](#image)
+### page-container
+
+页面容器。可在页面内创建一个覆盖于页面之上的子页面容器,常用于实现弹出层、抽屉等交互效果。
+
+属性
+
+| 属性名 | 类型 | 默认值 | 说明 |
+| ----- | ---- | ----- | ---- |
+| show | boolean | `false` | 是否显示容器 |
+| duration | number | `300` | 动画时长,单位毫秒 |
+| z-index | number | `100` | z-index 层级 |
+| overlay | boolean | `true` | 是否显示遮罩层 |
+| position | string | `bottom` | 弹出位置,可选值为 `top`、`bottom`、`right`、`center` |
+| round | boolean | `false` | 是否显示圆角,仅 `position` 为 `top` 或 `bottom` 时生效 |
+| close-on-slide-down | boolean | `false` | 是否在下滑时关闭容器,仅 `position` 为 `bottom` 时生效 |
+| overlay-style | string | | 自定义遮罩层样式 |
+| custom-style | string | | 自定义容器样式 |
+
+事件
+
+| 事件名 | 说明 |
+| ----- | ---- |
+| bindbeforeenter | 进入前触发 |
+| bindenter | 进入时触发 |
+| bindafterenter | 进入后触发 |
+| bindbeforeleave | 离开前触发 |
+| bindleave | 离开时触发 |
+| bindafterleave | 离开后触发 |
+| bindclickoverlay | 点击遮罩层时触发 |
+| bindclose | RN 环境特有事件,容器关闭时触发,用于同步 `show` 状态到父组件,`event.detail = { value: false }` |
+
+> [!tip] 注意
+>
+> - 在 RN 环境下,当容器显示时会禁用页面手势返回(iOS 左滑返回),并拦截系统返回事件,触发 `bindclose` 事件而非直接返回上一页。因此必须监听 `bindclose` 事件并将 `show` 同步设置为 `false`,否则无法正常关闭容器。
+> - 为什么需要 `bindclose`?特殊场景:在微信小程序中 `show={{a}}` 在关闭`page-container`后,a会变为false(类似wx:model的效果),所以需要bindclose来模拟此效果
+
+示例
+
+```html
+
+
+
+
+
+ 容器内容
+
+
+
+
+
+
+```
+
## 自定义组件 {#custom-component}
diff --git a/packages/core/@types/index.d.ts b/packages/core/@types/index.d.ts
index 41c6840928..2ea5512aca 100644
--- a/packages/core/@types/index.d.ts
+++ b/packages/core/@types/index.d.ts
@@ -433,6 +433,19 @@ export interface RnConfig {
* @returns Promise Resolves 为 true 表示权限获取成功,false 表示失败。
*/
cameraPermission?: () => Promise
+
+ /**
+ * 禁用或启用 iOS 页面左滑手势返回。
+ *
+ * 当 `page-container` 组件显示时,框架会调用此方法禁用手势返回,以防止与容器交互冲突;
+ * 容器关闭或销毁时会再次调用以恢复。
+ *
+ * 主要用于 DRN(混合容器)等场景,在纯 RN 环境下框架会通过 React Navigation 的
+ * `gestureEnabled` 选项自动处理,无需配置此项。
+ *
+ * @param options.disable true 表示禁用手势返回,false 表示恢复
+ */
+ disableSwipeBack?: (options: { disable: boolean }) => void
}
interface MpxConfig {
diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js
index b5a65229fb..db46b1a417 100644
--- a/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js
+++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js
@@ -42,6 +42,7 @@ const wxs = require('./wxs')
const fixComponentName = require('./fix-component-name')
const customBuiltInComponent = require('./custom-built-in-component')
const rootPortal = require('./root-portal')
+const pageContainer = require('./page-container')
const stickyHeader = require('./sticky-header')
const stickySection = require('./sticky-section')
@@ -142,6 +143,7 @@ module.exports = function getComponentConfigs ({ warn, error }) {
hyphenTagName({ print }),
label({ print }),
rootPortal({ print }),
+ pageContainer({ print }),
stickyHeader({ print }),
stickySection({ print }),
defaultCatchAllComponentConfig()
diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js
new file mode 100644
index 0000000000..30ae83fcd7
--- /dev/null
+++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js
@@ -0,0 +1,19 @@
+const TAG_NAME = 'page-container'
+
+module.exports = function ({ print }) {
+ return {
+ test: TAG_NAME,
+ ios (tag, { el }) {
+ el.isBuiltIn = true
+ return 'mpx-page-container'
+ },
+ android (tag, { el }) {
+ el.isBuiltIn = true
+ return 'mpx-page-container'
+ },
+ harmony (tag, { el }) {
+ el.isBuiltIn = true
+ return 'mpx-page-container'
+ }
+ }
+}
diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js
index 0c880ea06a..c5dd753820 100644
--- a/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js
+++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js
@@ -13,7 +13,7 @@ const JD_UNSUPPORTED_TAG_NAME_ARR = ['functional-page-navigator', 'live-pusher',
// 快应用不支持的标签集合
const QA_UNSUPPORTED_TAG_NAME_ARR = ['movable-view', 'movable-area', 'open-data', 'official-account', 'editor', 'functional-page-navigator', 'live-player', 'live-pusher', 'ad', 'cover-image']
// RN不支持的标签集合
-const RN_UNSUPPORTED_TAG_NAME_ARR = ['open-data', 'official-account', 'editor', 'functional-page-navigator', 'live-player', 'live-pusher', 'ad', 'audio', 'match-media', 'page-container', 'editor', 'keyboard-accessory', 'map']
+const RN_UNSUPPORTED_TAG_NAME_ARR = ['open-data', 'official-account', 'editor', 'functional-page-navigator', 'live-player', 'live-pusher', 'ad', 'audio', 'match-media', 'editor', 'keyboard-accessory', 'map']
/**
* @param {function(object): function} print
diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx
new file mode 100644
index 0000000000..222a32f1ac
--- /dev/null
+++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx
@@ -0,0 +1,473 @@
+/**
+ * ✔ show
+ * ✔ duration
+ * ✔ z-index
+ * ✔ overlay
+ * ✔ position
+ * ✔ round
+ * ✔ close-on-slide-down
+ * ✔ overlay-style
+ * ✔ custom-style
+ * ✔ bindbeforeenter
+ * ✔ bindenter
+ * ✔ bindafterenter
+ * ✔ bindbeforeleave
+ * ✔ bindleave
+ * ✔ bindafterleave
+ * ✔ bindclickoverlay
+ * ✔ bindclose RN下特有属性,用于同步show状态到父组件
+ */
+import React, { createElement, forwardRef, useEffect, useRef, useState } from 'react'
+import { StyleSheet, BackHandler, TouchableWithoutFeedback, StyleProp, ViewStyle } from 'react-native'
+import Animated, { useSharedValue, useAnimatedStyle, withTiming, cancelAnimation, runOnJS, WithTimingConfig, Easing, AnimationCallback } from 'react-native-reanimated'
+import { GestureDetector, Gesture } from 'react-native-gesture-handler'
+import Portal from './mpx-portal/index'
+import { usePreventRemove } from '@react-navigation/native'
+import { extendObject, useLayout, useNavigation, useRunOnJSCallback } from './utils'
+import useInnerProps, { getCustomEvent } from './getInnerListeners'
+import useNodesRef from './useNodesRef'
+import { noop } from '@mpxjs/utils'
+
+type Position = 'top' | 'bottom' | 'right' | 'center';
+
+interface PageContainerProps {
+ show: boolean;
+ duration?: number;
+ 'z-index'?: number;
+ overlay?: boolean;
+ position?: Position;
+ round?: boolean;
+ 'close-on-slide-down'?: boolean;
+ 'overlay-style'?: StyleProp;
+ 'custom-style'?: StyleProp;
+ bindbeforeenter?: (event: CustomEvent) => void;
+ bindenter?: (event: CustomEvent) => void;
+ bindafterenter?: (event: CustomEvent) => void;
+ bindbeforeleave?: (event: CustomEvent) => void;
+ bindleave?: (event: CustomEvent) => void;
+ bindafterleave?: (event: CustomEvent) => void;
+ bindclickoverlay?: (event: CustomEvent) => void;
+
+ bindclose: (event: CustomEvent<{ value: boolean}>) => void;
+ children: React.ReactNode;
+}
+
+function nextTick (cb: () => void) {
+ setTimeout(cb, 0)
+}
+
+function getInitialTranslate (position: Position) {
+ switch (position) {
+ case 'top':
+ return -100
+ case 'bottom':
+ return 100
+ case 'right':
+ return 100
+ case 'center':
+ return 0
+ }
+}
+function getRoundStyle (position: Position) {
+ switch (position) {
+ case 'top':
+ return {
+ borderBottomLeftRadius: 20,
+ borderBottomRightRadius: 20
+ }
+ case 'bottom':
+ return {
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20
+ }
+ default: return {}
+ }
+}
+
+// 禁用页面返回
+function useDisablePageBack (show: boolean, close: () => void) {
+ /**
+ * 如果当前页面是首页,则需要拦截返回关闭容器的逻辑
+ * 如果不是首页,则由RN逻辑完成拦截(usePreventRemove)
+ */
+ const navigation = useNavigation()!
+ // TODO resetRouterStack 可能会导致 isFirstPage 发生变化,需要监听路由变化
+ const isFirstPage = navigation.getState().routes.length === 1
+ const onBackPressRef = useRef(() => false as boolean)
+ onBackPressRef.current = () => {
+ if (show) {
+ close()
+ return true
+ }
+ return false
+ }
+
+ const backHandlerSubscriptionRef = useRef | null>(null)
+ const addBackPressListener = () => {
+ backHandlerSubscriptionRef.current = BackHandler.addEventListener('hardwareBackPress', () => onBackPressRef.current())
+ }
+ const removeBackPressListener = () => {
+ backHandlerSubscriptionRef.current?.remove()
+ backHandlerSubscriptionRef.current = null
+ }
+
+ // 记录组件挂载时的初始 gestureEnabled 值,用于组件销毁时还原
+ const originalGestureEnabledRef = useRef(
+ navigation.getState().routes.at(-1)?.options?.gestureEnabled ?? true
+ )
+
+ useEffect(() => {
+ if (isFirstPage) {
+ if (typeof global.__mpx.config?.rnConfig?.disableSwipeBack === 'function') {
+ // DRN 问题,当 resetRouterStack 页面数为1时会关闭 disableSwipeBack,此时需要再次 disableSwipeBack
+ global.__mpx.config.rnConfig.disableSwipeBack({ disable: show })
+ }
+
+ // 首页的返回事件无法通过usePreventRemove拦截,所以通过监听物理返回事件的方式来拦截
+ if (__mpx_mode__ !== 'ios') {
+ removeBackPressListener()
+ if (show) {
+ addBackPressListener()
+ }
+ }
+ return () => {
+ removeBackPressListener()
+ if (typeof global.__mpx.config?.rnConfig?.disableSwipeBack === 'function') {
+ global.__mpx.config.rnConfig.disableSwipeBack({ disable: false })
+ }
+ }
+ } else if (__mpx_mode__ === 'ios' && show) {
+ // 原生导航栏部分手势容器无法覆盖到,通过设置 gestureEnabled 来禁止系统手势返回
+ navigation.setOptions({ gestureEnabled: false })
+ return () => {
+ navigation.setOptions({ gestureEnabled: originalGestureEnabledRef.current })
+ }
+ }
+ }, [show])
+
+ // 路由返回拦截
+ usePreventRemove(show, () => {
+ close()
+ })
+}
+
+const PageContainer = forwardRef((props, ref) => {
+ const {
+ show,
+ duration = 300,
+ 'z-index': zIndex = 100,
+ overlay = true,
+ position = 'bottom',
+ round = false,
+ 'close-on-slide-down': closeOnSlideDown = false,
+ 'overlay-style': overlayStyle,
+ 'custom-style': customStyle,
+ bindclose, // RN下特有属性,用于同步show状态到父组件
+ bindbeforeenter,
+ bindenter,
+ bindafterenter,
+ bindbeforeleave,
+ bindleave,
+ bindafterleave,
+ bindclickoverlay,
+ children
+ } = props
+
+ const isFirstRenderFlag = useRef(true)
+ const isFirstRender = isFirstRenderFlag.current
+ if (isFirstRenderFlag.current) {
+ isFirstRenderFlag.current = false
+ }
+
+ const triggerBeforeEnterEvent = () => bindbeforeenter?.(getCustomEvent('beforeenter', {}, {}, props))
+ const triggerEnterEvent = () => bindenter?.(getCustomEvent('enter', {}, {}, props))
+ const triggerAfterEnterEvent = () => bindafterenter?.(getCustomEvent('afterenter', {}, {}, props))
+ const triggerBeforeLeaveEvent = () => bindbeforeleave?.(getCustomEvent('beforeleave', {}, {}, props))
+ const triggerLeaveEvent = () => bindleave?.(getCustomEvent('leave', {}, {}, props))
+ const triggerAfterLeaveEvent = () => bindafterleave?.(getCustomEvent('afterleave', {}, {}, props))
+
+ const close = () => bindclose(getCustomEvent('close', {}, { detail: { value: false, source: 'close' } }, props))
+
+ // 控制组件是否挂载
+ const [internalVisible, setInternalVisible] = useState(show)
+
+ const overlayOpacity = useSharedValue(0)
+ const contentOpacity = useSharedValue(position === 'center' ? 0 : 1)
+ const contentTranslate = useSharedValue(getInitialTranslate(position))
+
+ const overlayAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: overlayOpacity.value
+ }))
+
+ const sharedPosition = useSharedValue(position)
+ useEffect(() => {
+ sharedPosition.set(position)
+ }, [position])
+ const contentAnimatedStyle = useAnimatedStyle(() => {
+ let transform: ViewStyle['transform'] = []
+ if (sharedPosition.value === 'top' || sharedPosition.value === 'bottom') {
+ transform = [{ translateY: `${contentTranslate.value}%` }]
+ } else if (sharedPosition.value === 'right') {
+ transform = [{ translateX: `${contentTranslate.value}%` }]
+ }
+
+ return {
+ opacity: contentOpacity.value,
+ transform
+ }
+ })
+
+ const currentTick = useRef(0)
+ function createTick () {
+ currentTick.current++
+ const current = currentTick.current
+ return () => {
+ return currentTick.current === current
+ }
+ }
+
+ const invokeRunOnJSRef = useRef({
+ animateInEnd: noop,
+ animateOutEnd: noop,
+ close
+ })
+ invokeRunOnJSRef.current.close = close
+ const invokeRunOnJS = useRunOnJSCallback(invokeRunOnJSRef)
+
+ function clearAnimation () {
+ cancelAnimation(overlayOpacity)
+ cancelAnimation(contentOpacity)
+ cancelAnimation(contentTranslate)
+ }
+
+ // 播放入场动画
+ const animateIn = () => {
+ const isCurrentTick = createTick()
+ triggerBeforeEnterEvent()
+ nextTick(() => {
+ const animateOutFinish = internalVisible === false
+ setInternalVisible(true)
+ triggerEnterEvent()
+ if (!isCurrentTick()) return
+
+ invokeRunOnJSRef.current.animateInEnd = () => {
+ if (!isCurrentTick()) return
+ triggerAfterEnterEvent()
+ }
+ /**
+ * 对齐 微信小程序
+ * 如果退场动画已经结束,则需将内容先移动到对应动画的初始位置
+ * 否则,结束退场动画,从当前位置作为初始位置完成进场动画,且退场动画时长将缩短
+ */
+ let durationTime = duration
+ if (animateOutFinish) {
+ contentTranslate.set(getInitialTranslate(position))
+ contentOpacity.set(position === 'center' ? 0 : 1)
+ } else {
+ clearAnimation()
+ if (position === 'center') {
+ durationTime = durationTime * (1 - contentOpacity.value)
+ } else {
+ durationTime = durationTime * (Math.abs(contentTranslate.value) / 100)
+ }
+ }
+
+ const timingConfig: WithTimingConfig = {
+ duration: durationTime,
+ easing: Easing.out(Easing.quad)
+ }
+ const animationCallback: AnimationCallback = () => {
+ 'worklet'
+ runOnJS(invokeRunOnJS)('animateInEnd')
+ }
+
+ overlayOpacity.value = withTiming(1, timingConfig)
+ if (position === 'center') {
+ contentOpacity.value = withTiming(1, timingConfig, animationCallback)
+ contentTranslate.value = withTiming(0, timingConfig)
+ } else {
+ contentOpacity.value = withTiming(1, timingConfig)
+ contentTranslate.value = withTiming(0, timingConfig, animationCallback)
+ }
+ })
+ }
+
+ // 播放离场动画
+ const animateOut = () => {
+ const isCurrentTick = createTick()
+ triggerBeforeLeaveEvent()
+ nextTick(() => {
+ triggerLeaveEvent()
+ if (!isCurrentTick()) return
+
+ invokeRunOnJSRef.current.animateOutEnd = () => {
+ if (!isCurrentTick()) return // 如果动画被cancelAnimation依然会触发回调,所以在此也需要判断Tick
+ triggerAfterLeaveEvent()
+ setInternalVisible(false)
+ }
+
+ let durationTime = duration
+ if (position === 'center') {
+ durationTime = durationTime * (contentOpacity.value)
+ } else {
+ // durationTime * (1 - |contentTranslate| / 100)
+ durationTime = durationTime * (1 - Math.abs(contentTranslate.value) / 100)
+ }
+ const timingConfig: WithTimingConfig = {
+ duration: durationTime,
+ easing: Easing.out(Easing.quad)
+ }
+ const animationCallback: AnimationCallback = () => {
+ 'worklet'
+ runOnJS(invokeRunOnJS)('animateOutEnd')
+ }
+ if (position === 'center') {
+ contentOpacity.value = withTiming(0, timingConfig, animationCallback)
+ contentTranslate.value = withTiming(getInitialTranslate(position), timingConfig)
+ } else {
+ contentOpacity.value = withTiming(1, timingConfig)
+ contentTranslate.value = withTiming(getInitialTranslate(position), timingConfig, animationCallback)
+ }
+ overlayOpacity.value = withTiming(0, timingConfig)
+ })
+ }
+
+ useEffect(() => {
+ // 如果展示状态和挂载状态一致,则不需要做任何操作
+ if (show) {
+ animateIn()
+ } else {
+ if (!isFirstRender) animateOut()
+ }
+ }, [show])
+
+ useDisablePageBack(show, close)
+
+ const contentGesture = Gesture.Pan()
+ .activeOffsetY([0, 0]) // 只要开始下滑就激活
+ .failOffsetX([-10, 10]) // 横向位移超过 10px 则 fail,确保只处理纵向滑动
+ .onUpdate((e) => {
+ if (e.translationY > 200) {
+ runOnJS(invokeRunOnJS)('close')
+ }
+ })
+ /**
+ * 全屏幕 IOS 左滑手势返回(ios默认页面返回存在页面跟手逻辑,page-container暂不支持,对齐微信小程序)
+ * 1: 仅在屏幕左边缘滑动 才触发返回手势。
+ */
+ const screenGestureBase = Gesture.Pan()
+ .activeOffsetX([0, 0]) // 只要开始右滑就激活
+ .failOffsetY([-10, 10]) // 纵向位移超过 10px 则 fail,确保只处理横向滑动
+ .hitSlop({ left: 0, width: 30 }) // 从屏幕左侧 30px 内触发才处理
+ .onUpdate((e) => {
+ if (e.translationX > 10) {
+ runOnJS(invokeRunOnJS)('close')
+ }
+ })
+ // closeOnSlideDown 时与 contentGesture 并行识别,避免两者竞争
+ const screenGesture = closeOnSlideDown
+ ? screenGestureBase.simultaneousWithExternalGesture(contentGesture)
+ : screenGestureBase
+
+ const renderMask = () => {
+ const onPress = () => {
+ close()
+ bindclickoverlay?.(getCustomEvent(
+ 'clickoverlay',
+ {},
+ { detail: { value: false, source: 'clickoverlay' } },
+ props
+ ))
+ }
+ return createElement(TouchableWithoutFeedback, { onPress },
+ createElement(Animated.View, { style: [styles.overlay, overlayStyle, overlayAnimatedStyle] }))
+ }
+
+ const renderContent = () => {
+ const contentView = (
+
+ {children}
+
+ )
+
+ return closeOnSlideDown
+ ? {contentView}
+ : contentView
+ }
+
+ const nodeRef = useRef(null)
+ useNodesRef(props, ref, nodeRef, {})
+ const { layoutRef, layoutProps } = useLayout({ props, hasSelfPercent: false, nodeRef })
+ const innerProps = useInnerProps(
+ extendObject(
+ {},
+ props,
+ {
+ ref: nodeRef
+ },
+ layoutProps
+ ),
+ [],
+ { layoutRef }
+ )
+ const wrapperProps = extendObject(
+ innerProps,
+ {
+ style: [styles.wrapper, { zIndex }]
+ }
+ )
+
+ const renderWrapped = () => {
+ const wrappedView = (
+ // TODO 若开启pointerEvents="box-none" screenGesture 应包裹在page/app中才能保证手势全屏生效,如不开启pointerEvents="box-none" 则后方元素不可操作
+
+ {overlay ? renderMask() : null}
+ {renderContent()}
+
+ )
+ return __mpx_mode__ === 'ios'
+ ? {wrappedView}
+ : wrappedView
+ }
+
+ // TODO 是否有必要支持refs? dataset?
+ return createElement(Portal, null, internalVisible ? renderWrapped() : null)
+})
+
+const styles = StyleSheet.create({
+ wrapper: extendObject(
+ {
+ justifyContent: 'flex-end',
+ alignItems: 'center'
+ } as const,
+ StyleSheet.absoluteFillObject
+ ),
+ overlay: extendObject(
+ {
+ backgroundColor: 'rgba(0,0,0,0.5)'
+ },
+ StyleSheet.absoluteFillObject
+ ),
+ container: {
+ position: 'absolute',
+ backgroundColor: 'white',
+ overflow: 'hidden'
+ }
+})
+
+const positionStyle: Record = {
+ bottom: { bottom: 0, width: '100%', height: 'auto' },
+ top: { top: 0, width: '100%', height: 'auto' },
+ right: extendObject({}, StyleSheet.absoluteFillObject, { right: 0 }),
+ center: extendObject({}, StyleSheet.absoluteFillObject)
+}
+
+PageContainer.displayName = 'MpxPageContainer'
+
+export default PageContainer
diff --git a/packages/webpack-plugin/lib/template-compiler/compiler.js b/packages/webpack-plugin/lib/template-compiler/compiler.js
index 26e7d7e27f..223d194596 100644
--- a/packages/webpack-plugin/lib/template-compiler/compiler.js
+++ b/packages/webpack-plugin/lib/template-compiler/compiler.js
@@ -41,7 +41,7 @@ const endTag = new RegExp(('^<\\/' + qnameCapture + '[^>]*>'))
const doctype = /^]+>/i
const comment = /^