diff --git a/packages/api-proxy/src/platform/api/base/rnCanIUseConfig.js b/packages/api-proxy/src/platform/api/base/rnCanIUseConfig.js index ce316fde95..2313c391cb 100644 --- a/packages/api-proxy/src/platform/api/base/rnCanIUseConfig.js +++ b/packages/api-proxy/src/platform/api/base/rnCanIUseConfig.js @@ -224,5 +224,13 @@ export const SUPPORTED_OBJECTS = { 'abort', 'onHeadersReceived', 'offHeadersReceived' + ], + + // camera + CameraContext: [ + 'setZoom', + 'takePhoto', + 'startRecord', + 'stopRecord' ] } diff --git a/packages/api-proxy/src/platform/api/camera/index.ios.js b/packages/api-proxy/src/platform/api/camera/index.ios.js new file mode 100644 index 0000000000..4ea712dfaf --- /dev/null +++ b/packages/api-proxy/src/platform/api/camera/index.ios.js @@ -0,0 +1,9 @@ +import CreateCamera from './rnCamera' + +function createCameraContext () { + return new CreateCamera() +} + +export { + createCameraContext +} diff --git a/packages/api-proxy/src/platform/api/camera/index.js b/packages/api-proxy/src/platform/api/camera/index.js new file mode 100644 index 0000000000..1df0bbc389 --- /dev/null +++ b/packages/api-proxy/src/platform/api/camera/index.js @@ -0,0 +1,7 @@ +import { ENV_OBJ, envError } from '../../../common/js' + +const createCameraContext = ENV_OBJ.createCameraContext || envError('createCameraContext') + +export { + createCameraContext +} diff --git a/packages/api-proxy/src/platform/api/camera/rnCamera.js b/packages/api-proxy/src/platform/api/camera/rnCamera.js new file mode 100644 index 0000000000..9dec79673c --- /dev/null +++ b/packages/api-proxy/src/platform/api/camera/rnCamera.js @@ -0,0 +1,44 @@ +import { noop, getFocusedNavigation } from '@mpxjs/utils' + +export default class CreateCamera { + constructor () { + const navigation = getFocusedNavigation() || {} + this.camera = navigation.camera || {} + } + + setZoom (options = {}) { + const { zoom, success = noop, fail = noop, complete = noop } = options + try { + if (this.camera.setZoom) { + const result = { errMsg: 'setZoom:ok' } + success(result) + complete(result) + this.camera.setZoom(zoom) + } else { + const result = { + errMsg: 'setZoom:fail camera instance not found' + } + fail(result) + complete(result) + } + } catch (error) { + const result = { + errMsg: 'setZoom:fail ' + (error?.message || '') + } + fail(result) + complete(result) + } + } + + takePhoto (options) { + this.camera?.takePhoto(options) + } + + startRecord (options) { + this.camera?.startRecord(options) + } + + stopRecord (options) { + this.camera?.stopRecord(options) + } +} diff --git a/packages/api-proxy/src/platform/index.js b/packages/api-proxy/src/platform/index.js index 143986e12e..ba1ebd2c14 100644 --- a/packages/api-proxy/src/platform/index.js +++ b/packages/api-proxy/src/platform/index.js @@ -122,3 +122,6 @@ export * from './api/keyboard' // getSetting, openSetting, enableAlertBeforeUnload, disableAlertBeforeUnload, getMenuButtonBoundingClientRect export * from './api/setting' + +// createCameraContext +export * from './api/camera' diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/camera.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/camera.js index 3d371de773..6306473283 100644 --- a/packages/webpack-plugin/lib/platform/template/wx/component-config/camera.js +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/camera.js @@ -18,6 +18,18 @@ module.exports = function ({ print }) { return { test: TAG_NAME, + ios (tag, { el }) { + el.isBuiltIn = true + return 'mpx-camera' + }, + android (tag, { el }) { + el.isBuiltIn = true + return 'mpx-camera' + }, + harmony (tag, { el }) { + el.isBuiltIn = true + return 'mpx-camera' + }, props: [ { test: 'mode', 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 268add9eac..0c880ea06a 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', 'camera', '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', 'page-container', 'editor', 'keyboard-accessory', 'map'] /** * @param {function(object): function} print diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx new file mode 100644 index 0000000000..b45b0cec14 --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-camera.tsx @@ -0,0 +1,358 @@ +import { createElement, forwardRef, useRef, useCallback, useContext, useState, useEffect, useMemo } from 'react' +import { getCurrentPage, useTransformStyle, useLayout, extendObject } from './utils' +import useInnerProps, { getCustomEvent } from './getInnerListeners' +import { noop, warn, hasOwn } from '@mpxjs/utils' +import { RouteContext } from './context' +import { watch, WatchOptions } from '@mpxjs/core' + +const qualityValue = { + high: 90, + normal: 75, + low: 50, + original: 100 +} + +interface CameraProps { + mode?: 'normal' | 'scanCode' + resolution?: 'low' | 'medium' | 'high' + 'device-position'?: 'front' | 'back' + flash?: 'auto' | 'on' | 'off' + 'frame-size'?: 'small' | 'medium' | 'large' + style?: Record + bindstop?: () => void + binderror?: (error: { message: string }) => void + bindinitdone?: (result: { type: string, data: string }) => void + bindscancode?: (result: { type: string, data: string }) => void + 'parent-font-size'?: number + 'parent-width'?: number + 'parent-height'?: number + 'enable-var'?: boolean + 'external-var-context'?: any +} + +interface TakePhotoOptions { + quality?: 'high' | 'normal' | 'low' | 'original' + success?: (result: { errMsg: string, tempImagePath: string }) => void + fail?: (result: { errMsg: string }) => void + complete?: (result: { errMsg: string, tempImagePath?: string }) => void +} + +interface RecordOptions { + timeout?: number + success?: (result: { errMsg: string }) => void + fail?: (result: { errMsg: string, error?: any }) => void + complete?: (result: { errMsg: string }) => void + timeoutCallback?: (result: { errMsg: string, error?: any }) => void +} + +interface StopRecordOptions { + success?: (result: { errMsg: string, tempVideoPath: string, duration: number }) => void + fail?: (result: { errMsg: string }) => void + complete?: (result: { errMsg: string, tempVideoPath?: string, duration?: number }) => void +} + +interface CameraRef { + setZoom: (zoom: number) => void + takePhoto: (options?: TakePhotoOptions) => void + startRecord: (options?: RecordOptions) => void + stopRecord: (options?: StopRecordOptions) => void +} + +type HandlerRef = { + current: T | null +} + +let RecordRes: any = null + +const _camera = forwardRef, CameraProps>((props: CameraProps, ref): JSX.Element | null => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Camera, useCameraDevice, useCodeScanner, useCameraFormat } = require('react-native-vision-camera') + const cameraRef = useRef(null) + const { + mode = 'normal', + resolution = 'medium', + 'device-position': devicePosition = 'back', + flash = 'auto', + 'frame-size': frameSize = 'medium', + bindinitdone, + bindstop, + bindscancode, + 'parent-font-size': parentFontSize, + 'parent-width': parentWidth, + 'parent-height': parentHeight, + 'enable-var': enableVar, + 'external-var-context': externalVarContext, + style = {} + } = props + const styleObj = extendObject( + {}, + style + ) + const { + normalStyle, + hasSelfPercent, + setWidth, + setHeight + } = useTransformStyle(styleObj, { + enableVar, + externalVarContext, + parentFontSize, + parentWidth, + parentHeight + }) + const { layoutRef, layoutStyle, layoutProps } = useLayout({ props, hasSelfPercent, setWidth, setHeight, nodeRef: cameraRef }) + const isPhoto = useRef(false) + isPhoto.current = mode === 'normal' + const device = useCameraDevice(devicePosition || 'back') + const { navigation, pageId } = useContext(RouteContext) || {} + const [zoomValue, setZoomValue] = useState(1) + const [isActive, setIsActive] = useState(true) + const [hasPermission, setHasPermission] = useState(null) + const page = getCurrentPage(pageId) + + // 先定义常量,避免在条件判断后使用 + const maxZoom = device?.maxZoom || 1 + const RESOLUTION_MAPPING: Record = { + low: { width: 1280, height: 720 }, + medium: { width: 1920, height: 1080 }, + high: 'max' + } + const FRAME_SIZE_MAPPING: Record = { + small: { width: 1280, height: 720 }, + medium: { width: 1920, height: 1080 }, + large: 'max' + } + + const format = useCameraFormat(device, [ + { + photoResolution: RESOLUTION_MAPPING[resolution], + videoResolution: FRAME_SIZE_MAPPING[frameSize] || RESOLUTION_MAPPING[resolution] + } + ]) + const isScancode = useCallback((fail: (res: { errMsg: string }) => void, complete: (res: { errMsg: string }) => void) => { + if (!isPhoto.current) { + const result = { + errMsg: 'Not allow to invoke takePhoto in \'scanCode\' mode.' + } + fail(result) + complete(result) + return true + } + return false + }, []) + const codeScanner = useCodeScanner({ + codeTypes: ['qr'], + onCodeScanned: (codes: any[]) => { + codes.forEach(code => { + const type = code.type === 'qr' ? 'QR_CODE' : code.type?.toUpperCase() + const frame = code.frame || {} + bindscancode && bindscancode(getCustomEvent('scancode', {}, { + detail: { + result: code.value, + type, + scanArea: [parseInt(frame.x) || 0, parseInt(frame.y) || 0, parseInt(frame.width) || 0, parseInt(frame.height) || 0] + } + })) + }) + } + }) + + const onInitialized = useCallback(() => { + bindinitdone && bindinitdone(getCustomEvent('initdone', {}, { + detail: { + maxZoom + } + })) + }, [bindinitdone, maxZoom]) + + const onStopped = useCallback(() => { + bindstop && bindstop() + }, [bindstop]) + + const camera: CameraRef = useMemo(() => ({ + setZoom: (zoom: number) => { + setZoomValue(zoom) + }, + takePhoto: (options: TakePhotoOptions = {}) => { + const { success = noop, fail = noop, complete = noop } = options + if (isScancode(fail, complete)) return + cameraRef.current?.takePhoto?.({ + quality: qualityValue[options.quality || 'normal'] as number + } as any).then((res: { path: any }) => { + const result = { + errMsg: 'takePhoto:ok', + tempImagePath: res.path + } + success(result) + complete(result) + }).catch(() => { + const result = { + errMsg: 'takePhoto:fail' + } + fail(result) + complete(result) + }) + }, + startRecord: (options: RecordOptions = {}) => { + let { timeout = 30, success = noop, fail = noop, complete = noop, timeoutCallback = noop } = options + timeout = timeout > 300 ? 300 : timeout + let recordTimer: NodeJS.Timeout | null = null + if (isScancode(fail, complete)) return + try { + const result = { + errMsg: 'startRecord:ok' + } + success(result) + complete(result) + + cameraRef.current?.startRecording?.({ + onRecordingError: (error: any) => { + if (recordTimer) clearTimeout(recordTimer) + const errorResult = { + errMsg: 'startRecord:fail during recording', + error: error + } + timeoutCallback(errorResult) + }, + onRecordingFinished: (video: any) => { + RecordRes = video + if (recordTimer) clearTimeout(recordTimer) + } + }) + + recordTimer = setTimeout(() => { // 超时自动停止 + cameraRef.current?.stopRecording().catch(() => { + // 忽略停止录制时的错误 + }) + }, timeout * 1000) + } catch (error: any) { + if (recordTimer) clearTimeout(recordTimer) + const result = { + errMsg: 'startRecord:fail ' + (error.message || 'unknown error') + } + fail(result) + complete(result) + } + }, + stopRecord: (options: StopRecordOptions = {}) => { + const { success = noop, fail = noop, complete = noop } = options + if (isScancode(fail, complete)) return + try { + cameraRef.current?.stopRecording().then(() => { + setTimeout(() => { + if (RecordRes) { + const result = { + errMsg: 'stopRecord:ok', + tempVideoPath: RecordRes?.path, + duration: RecordRes.duration * 1000 // 转成ms + } + RecordRes = null + success(result) + complete(result) + } + }, 200) // 延时200ms,确保录制结果已准备好 + }).catch((e: any) => { + const result = { + errMsg: 'stopRecord:fail ' + (e.message || 'promise rejected') + } + fail(result) + complete(result) + }) + } catch (error: any) { + const result = { + errMsg: 'stopRecord:fail ' + (error.message || 'unknown error') + } + fail(result) + complete(result) + } + } + }), []) + + useEffect(() => { + let unWatch: any + if (pageId && hasOwn(global.__mpxPageStatusMap, String(pageId))) { + unWatch = watch(() => global.__mpxPageStatusMap[pageId], (newVal: string) => { + if (newVal === 'show') { + if (page.id === pageId) { + setIsActive(true) + } + } + if (newVal === 'hide') { + setIsActive(false) + } + }, { sync: true } as WatchOptions) + } + const checkCameraPermission = async () => { + try { + const cameraPermission = global?.__mpx?.config?.rnConfig?.cameraPermission + if (typeof cameraPermission === 'function') { + const permissionResult = await cameraPermission() + setHasPermission(permissionResult === true) + } else { + setHasPermission(true) + } + } catch (error) { + setHasPermission(false) + } + } + checkCameraPermission() + return () => { + if (navigation?.camera === camera) { + delete navigation.camera + } + unWatch && unWatch() + } + }, []) + + const innerProps = useInnerProps( + extendObject( + {}, + props, + layoutProps, + { + ref: cameraRef, + style: extendObject({}, normalStyle, layoutStyle), + isActive, + photo: true, + video: true, + onInitialized, + onStopped, + device, + format, + codeScanner: !isPhoto.current ? codeScanner : undefined, + zoom: zoomValue, + torch: flash + } + ), + [ + 'mode', + 'resolution', + 'frame-size', + 'bindinitdone', + 'bindstop', + 'flash', + 'bindscancode', + 'binderror' + ], + { + layoutRef + } + ) + + if (navigation && navigation.camera && navigation.camera !== camera) { + warn(': 一个页面只能插入一个') + return null + } else if (navigation) { + navigation.camera = camera + } + + if (!hasPermission || !device) { + return null + } + + return createElement(Camera, innerProps) +}) + +_camera.displayName = 'MpxCamera' + +export default _camera diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx index 4f416c87e0..ff86251681 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx @@ -234,7 +234,7 @@ const _WebView = forwardRef, WebViewProps>((pr } break case 'postMessage': - bindmessage && bindmessage(getCustomEvent('messsage', {}, { // RN组件销毁顺序与小程序不一致,所以改成和支付宝消息一致 + bindmessage && bindmessage(getCustomEvent('message', {}, { // RN组件销毁顺序与小程序不一致,所以改成和支付宝消息一致 detail: { data: params[0]?.data } diff --git a/packages/webpack-plugin/lib/utils/dom-tag-config.js b/packages/webpack-plugin/lib/utils/dom-tag-config.js index 61d6cebe43..a07accc10f 100644 --- a/packages/webpack-plugin/lib/utils/dom-tag-config.js +++ b/packages/webpack-plugin/lib/utils/dom-tag-config.js @@ -91,7 +91,7 @@ const isBuildInReactTag = makeMap( 'mpx-movable-area,mpx-label,mpx-input,' + 'mpx-image,mpx-form,mpx-checkbox,mpx-checkbox-group,mpx-button,' + 'mpx-rich-text,mpx-picker-view-column,mpx-picker-view,mpx-picker,' + - 'mpx-icon,mpx-canvas' + 'mpx-icon,mpx-canvas,mpx-camera' ) const isSpace = makeMap('ensp,emsp,nbsp') diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 27801cfb07..774bc2abb6 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -97,6 +97,7 @@ "react-native-safe-area-context": "^4.12.0", "react-native-svg": "^15.8.0", "react-native-video": "^6.9.0", + "react-native-vision-camera": "^4.7.2", "react-native-webview": "^13.12.2", "rimraf": "^6.0.1", "webpack": "^5.75.0"