diff --git a/.agents/skills/mpx2rn/references/rn-style-reference.md b/.agents/skills/mpx2rn/references/rn-style-reference.md index cb86ce2a6d..f0ec5bf331 100644 --- a/.agents/skills/mpx2rn/references/rn-style-reference.md +++ b/.agents/skills/mpx2rn/references/rn-style-reference.md @@ -405,7 +405,7 @@ Mpx 在 RN 平台支持 CSS 背景图及渐变背景,框架会自动处理样 **限制与注意事项:** - **组件限制**:仅 `view` 组件支持除 `background-color` 外的背景相关属性。 -- **简写属性**:`background` 简写属性仅支持 ``、`` 和 ``,不支持 `background-position` 和 `background-size` 简写。 +- **简写属性**:`background` 简写属性支持 ``、``、``,以及用 `/` 分隔的 ` / ` 语法。 - **背景重复**:`background-repeat` 仅支持 `no-repeat`。 - **多重背景**:不支持多重背景。 - **渐变类型**:仅支持 `linear-gradient()` 线性渐变,不支持 `radial-gradient()`、`conic-gradient()` 等其他渐变类型。 @@ -497,7 +497,7 @@ Mpx 在 RN 平台支持 CSS 背景图及渐变背景,框架会自动处理样 | 属性 | 值类型 | 说明 | 示例 | | --- | --- | --- | --- | -| `background` | `` \| `` \| `` | 背景简写,不支持 `background-position` 和 `background-size` 简写 | `background: #f5f5f5`;`background: url(https://example.com/bg.png) no-repeat` | +| `background` | `` \| `` \| `` \| `` / `` | 背景简写,支持用 `/` 分隔的背景位置和尺寸 | `background: #f5f5f5`;`background: url(https://example.com/bg.png) no-repeat center/cover` | | `background-color` | `color` | 背景色 | `background-color: #fff`;`background-color: rgba(0, 0, 0, 0.5)` 半透明黑色 | | `background-image` | `url()` \| `linear-gradient()` \| `none` | 背景图/渐变 | `background-image: url(https://example.com/bg.png)`;`background-image: linear-gradient(to bottom, #ff0000, #0000ff)`;`background-image: none` | | `background-size` | `cover` \| `contain` \| `auto` \| `length` \| `%` | 背景尺寸 | `background-size: cover` 覆盖填充;`background-size: 200rpx 100rpx` | diff --git a/docs-vitepress/guide/rn/style.md b/docs-vitepress/guide/rn/style.md index 8c6517eea9..deee921c08 100644 --- a/docs-vitepress/guide/rn/style.md +++ b/docs-vitepress/guide/rn/style.md @@ -303,7 +303,7 @@ Mpx 对通过 `class` 类定义的样式会按照 RN 的样式规则进行编译 | **文本相关** | `text-shadow`、`text-decoration` | | **布局相关** | `flex`、`flex-flow` | | **间距相关** | `margin`、`padding` | -| **背景相关** | `background` | +| **背景相关** | `background`(支持 `background-position / background-size`) | | **阴影相关** | `box-shadow` | | **边框相关** | `border-radius`、`border-width`、`border-color`、`border` | | **方向边框** | `border-top`、`border-right`、`border-bottom`、`border-left` | @@ -1032,6 +1032,7 @@ border-left: 2px dotted blue; /* 左边框:宽度 样式 颜色 */ ```css background: url("image.jpg") no-repeat center; +background: url("image.jpg") no-repeat center/cover; background: linear-gradient(45deg, red, blue); background: #f0f0f0; ``` diff --git a/packages/webpack-plugin/lib/platform/style/wx/index.js b/packages/webpack-plugin/lib/platform/style/wx/index.js index eb931b3eb9..f242f514a4 100644 --- a/packages/webpack-plugin/lib/platform/style/wx/index.js +++ b/packages/webpack-plugin/lib/platform/style/wx/index.js @@ -17,6 +17,7 @@ module.exports = function getSpec({ warn, error }) { // calc(xx) const calcExp = /calc\(/ const envExp = /env\(/ + const silentVerify = 'silent' // 不支持的属性提示 const unsupportedPropError = ({ prop, value, selector }, { mode }, isError = true) => { const tips = isError ? error : warn @@ -118,7 +119,7 @@ module.exports = function getSpec({ warn, error }) { const verifyValues = ({ prop, value, selector }, isError = true) => { prop = prop.trim() const rawValue = value.trim() - const tips = isError ? error : warn + const tips = isError === silentVerify ? () => {} : isError ? error : warn // CSS 自定义属性(--xxx)是变量定义,不属于 RN 样式属性: // 不能按 `-height/-color` 等后缀推断类型去校验,否则会把变量定义错误过滤,导致运行时 var() 取值失败 @@ -340,6 +341,36 @@ module.exports = function getSpec({ warn, error }) { } const urlExp = /url\(["']?(.*?)["']?\)/ const linearExp = /linear-gradient\(.*\)/ + const formatBackgroundSize = (value) => { + // 不支持逗号分隔的多个值:设置多重背景!!! + // 支持一个值:这个值指定图片的宽度,图片的高度隐式的为 auto + // 支持两个值:第一个值指定图片的宽度,第二个值指定图片的高度 + if (parseValues(value, ',').length > 1) { // commas are not allowed in values + error(`Value of [${bgPropMap.size}] in ${selector} does not support commas, received [${value}], please check again!`) + return false + } + const values = [] + parseValues(value).forEach(item => { + if (verifyValues({ prop: bgPropMap.size, value: item, selector })) { + // 支持 number 值 / container cover auto 枚举 + values.push(item) + } + }) + // value 无有效值时返回false + return values.length === 0 ? false : { prop: bgPropMap.size, value: values } + } + const formatBackgroundPosition = (value) => { + const values = [] + parseValues(value).forEach(item => { + if (verifyValues({ prop: bgPropMap.position, value: item, selector })) { + // 支持 number 值 / 枚举, center与50%等价 + values.push(item === 'center' ? '50%' : item) + } else { + error(`Value of [${bgPropMap.size}] in ${selector} does not support commas, received [${value}], please check again!`) + } + }) + return { prop: bgPropMap.position, value: values } + } switch (prop) { case bgPropMap.image: { // background-image 支持背景图/渐变/css var @@ -352,37 +383,13 @@ module.exports = function getSpec({ warn, error }) { } case bgPropMap.size: { // background-size - // 不支持逗号分隔的多个值:设置多重背景!!! - // 支持一个值:这个值指定图片的宽度,图片的高度隐式的为 auto - // 支持两个值:第一个值指定图片的宽度,第二个值指定图片的高度 - if (parseValues(value, ',').length > 1) { // commas are not allowed in values - error(`Value of [${bgPropMap.size}] in ${selector} does not support commas, received [${value}], please check again!`) - return false - } - const values = [] - parseValues(value).forEach(item => { - if (verifyValues({ prop, value: item, selector })) { - // 支持 number 值 / container cover auto 枚举 - values.push(item) - } - }) - // value 无有效值时返回false - return values.length === 0 ? false : { prop, value: values } + return formatBackgroundSize(value) } case bgPropMap.position: { - const values = [] - parseValues(value).forEach(item => { - if (verifyValues({ prop, value: item, selector })) { - // 支持 number 值 / 枚举, center与50%等价 - values.push(item === 'center' ? '50%' : item) - } else { - error(`Value of [${bgPropMap.size}] in ${selector} does not support commas, received [${value}], please check again!`) - } - }) - return { prop, value: values } + return formatBackgroundPosition(value) } case bgPropMap.all: { - // background: 仅支持 background-image & background-color & background-repeat + // background: 支持 image/color/repeat 与 position/size if (cssVariableExp.test(value)) { error(`Property [${bgPropMap.all}] in ${selector} is abbreviated property and does not support CSS var`) return false @@ -395,6 +402,37 @@ module.exports = function getSpec({ warn, error }) { ] } const bgMap = [] + const positionValues = [] + const sizeValues = [] + let isSize = false + const pushPositionOrSize = (item) => { + if (isSize) { + if (verifyValues({ prop: bgPropMap.size, value: item, selector }, silentVerify)) { + sizeValues.push(item) + } + } else if (verifyValues({ prop: bgPropMap.position, value: item, selector }, silentVerify)) { + positionValues.push(item) + } + } + const handlePositionSize = (item) => { + if (item === '/') { + isSize = true + return true + } + const parts = parseValues(item, '/') + if (parts.length > 1) { + parts.forEach((part, index) => { + if (index > 0) isSize = true + part && pushPositionOrSize(part) + }) + return true + } + if (isSize || verifyValues({ prop: bgPropMap.position, value: item, selector }, silentVerify)) { + pushPositionOrSize(item) + return true + } + return false + } const values = parseValues(value) values.forEach(item => { const url = item.match(urlExp)?.[0] @@ -403,12 +441,22 @@ module.exports = function getSpec({ warn, error }) { bgMap.push({ prop: bgPropMap.image, value: url }) } else if (linerVal) { bgMap.push({ prop: bgPropMap.image, value: linerVal }) - } else if (verifyValues({ prop: bgPropMap.color, value: item }, false)) { + } else if (verifyValues({ prop: bgPropMap.color, value: item, selector }, silentVerify)) { bgMap.push({ prop: bgPropMap.color, value: item }) - } else if (verifyValues({ prop: bgPropMap.repeat, value: item, selector }, false)) { + } else if (verifyValues({ prop: bgPropMap.repeat, value: item, selector }, silentVerify)) { bgMap.push({ prop: bgPropMap.repeat, value: item }) + } else { + handlePositionSize(item) } }) + if (positionValues.length) { + const position = formatBackgroundPosition(positionValues.join(' ')) + position && bgMap.push(position) + } + if (sizeValues.length) { + const size = formatBackgroundSize(sizeValues.join(' ')) + size && bgMap.push(size) + } return bgMap.length ? bgMap : false } } diff --git a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx index 0fd19cc9d0..f25af495df 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx @@ -7,12 +7,26 @@ import { initialWindowMetrics } from 'react-native-safe-area-context' import type { AnyFunc, ExtendedFunctionComponent } from './types/common' import { Gesture } from 'react-native-gesture-handler' -export const TEXT_STYLE_REGEX = /color|font.*|text.*|letterSpacing|lineHeight|includeFontPadding|writingDirection/ +export const TEXT_STYLE_MAP: Record = { + color: true, + letterSpacing: true, + lineHeight: true, + includeFontPadding: true, + writingDirection: true +} export const PERCENT_REGEX = /^\s*-?\d+(\.\d+)?%\s*$/ export const URL_REGEX = /^\s*url\(["']?(.*?)["']?\)\s*$/ export const SVG_REGEXP = /\.svg(?:[?#].*)?$/i -export const BACKGROUND_REGEX = /^background(Image|Size|Repeat|Position)$/ -export const TEXT_PROPS_REGEX = /ellipsizeMode|numberOfLines/ +export const BACKGROUND_STYLE_MAP: Record = { + backgroundImage: true, + backgroundSize: true, + backgroundRepeat: true, + backgroundPosition: true +} +export const TEXT_PROPS_MAP: Record = { + ellipsizeMode: true, + numberOfLines: true +} export const DEFAULT_FONT_SIZE = 16 export const HIDDEN_STYLE = { opacity: 0 @@ -32,10 +46,19 @@ const varUseRegExp = /var\(/ const unoVarDecRegExp = /^--un-/ const unoVarUseRegExp = /var\(--un-/ const calcUseRegExp = /calc\(/ -const calcPercentExp = /^calc\(.*-?\d+(\.\d+)?%.*\)$/ const envUseRegExp = /env\(/ -const filterRegExp = /(calc|env|%)/ -const boxSizingAffectingRegExp = /^(padding.*|border.*Width)$/ +const boxSizingAffectingStyleMap: Record = { + padding: true, + paddingTop: true, + paddingRight: true, + paddingBottom: true, + paddingLeft: true, + borderWidth: true, + borderTopWidth: true, + borderRightWidth: true, + borderBottomWidth: true, + borderLeftWidth: true +} const safeAreaInsetMap: Record = { 'safe-area-inset-top': 'top', @@ -58,7 +81,11 @@ export function transformBoxSizing (style: Record = {}, hasBoxSizin } export function isBoxSizingAffectingStyle (key: string) { - return boxSizingAffectingRegExp.test(key) + return hasOwn(boxSizingAffectingStyleMap, key) +} + +function isTextStyle (key: string) { + return hasOwn(TEXT_STYLE_MAP, key) || key.startsWith('font') || key.startsWith('text') } function getSafeAreaInset (name: string, navigation: Record | undefined) { @@ -153,9 +180,9 @@ export function splitStyle> (styleObj: T, sideEffe } { return groupBy(styleObj, (key, val) => { sideEffect && sideEffect(key, val) - if (TEXT_STYLE_REGEX.test(key)) { + if (isTextStyle(key)) { return 'textStyle' - } else if (BACKGROUND_REGEX.test(key)) { + } else if (hasOwn(BACKGROUND_STYLE_MAP, key)) { return 'backgroundStyle' } else { return 'innerStyle' @@ -481,13 +508,8 @@ export function useTransformStyle (styleObj: Record = {}, { enableV } } - function calcVisitor ({ key, value, keyPath }: VisitorArg) { + function calcVisitor ({ value, keyPath }: VisitorArg) { if (calcUseRegExp.test(value)) { - // calc translate & border-radius 的百分比计算 - if (hasOwn(selfPercentRule, key) && calcPercentExp.test(value)) { - hasSelfPercent = true - percentKeyPaths.push(keyPath.slice()) - } calcKeyPaths.push(keyPath.slice()) } } @@ -504,7 +526,7 @@ export function useTransformStyle (styleObj: Record = {}, { enableV } function visitOther ({ target, key, value, keyPath }: VisitorArg) { - if (filterRegExp.test(value)) { + if (typeof value === 'string' && (value.includes('%') || value.includes('calc(') || value.includes('env('))) { [envVisitor, percentVisitor, calcVisitor].forEach(visitor => visitor({ target, key, value, keyPath })) } } @@ -555,6 +577,9 @@ export function useTransformStyle (styleObj: Record = {}, { enableV // apply calc transformCalc(normalStyle, calcKeyPaths, (value: string, key: string) => { if (PERCENT_REGEX.test(value)) { + if (hasOwn(selfPercentRule, key)) { + hasSelfPercent = true + } const resolved = resolvePercent(value, key, percentConfig) return typeof resolved === 'number' ? resolved : 0 } else { @@ -639,7 +664,7 @@ export function splitProps> (props: T): { innerProps?: Partial } { return groupBy(props, (key) => { - if (TEXT_PROPS_REGEX.test(key)) { + if (hasOwn(TEXT_PROPS_MAP, key)) { return 'textProps' } else { return 'innerProps' diff --git a/packages/webpack-plugin/test/platform/wx/style-helper-rn.spec.js b/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js similarity index 87% rename from packages/webpack-plugin/test/platform/wx/style-helper-rn.spec.js rename to packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js index 9eeff9cbe9..1cbfbe568f 100644 --- a/packages/webpack-plugin/test/platform/wx/style-helper-rn.spec.js +++ b/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js @@ -1,4 +1,4 @@ -const { getClassMap } = require('../../../lib/react/style-helper') +const { getClassMap } = require('../../../../lib/react/style-helper') describe('React Native style validation for CSS variables', () => { const createConfig = (mode = 'ios') => ({ @@ -246,6 +246,49 @@ describe('React Native style validation for CSS variables', () => { }) }) + describe('Background shorthand', () => { + test('should expand background-position and background-size from slash shorthand', () => { + const css = '.bg { background: url(https://example.com/bg.png) no-repeat center/cover #fff; }' + const config = createConfig() + + const result = getClassMap({ + content: css, + filename: 'test.css', + ...config + }) + + expect(result.bg).toEqual({ + backgroundImage: '"url(https://example.com/bg.png)"', + backgroundRepeat: '"no-repeat"', + backgroundColor: '"#fff"', + backgroundPosition: ['"50%"'], + backgroundSize: ['"cover"'] + }) + expect(config.warn).not.toHaveBeenCalled() + expect(config.error).not.toHaveBeenCalled() + }) + + test('should expand background-position and background-size with spaced slash', () => { + const css = '.bg { background: url(bg.png) no-repeat left top / 100% 50%; }' + const config = createConfig() + + const result = getClassMap({ + content: css, + filename: 'test.css', + ...config + }) + + expect(result.bg).toEqual({ + backgroundImage: '"url(bg.png)"', + backgroundRepeat: '"no-repeat"', + backgroundPosition: ['"left"', '"top"'], + backgroundSize: ['"100%"', '"50%"'] + }) + expect(config.warn).not.toHaveBeenCalled() + expect(config.error).not.toHaveBeenCalled() + }) + }) + describe('Direct normal values (without CSS variables)', () => { test('should filter out direct letter-spacing: normal', () => { const css = '.text { letter-spacing: normal; }' diff --git a/solutions/rn-runtime-shorthand-style.md b/solutions/rn-runtime-shorthand-style.md new file mode 100644 index 0000000000..d2a71664e4 --- /dev/null +++ b/solutions/rn-runtime-shorthand-style.md @@ -0,0 +1,455 @@ +# Mpx2RN useTransformStyle 运行时简写属性展开方案 + +## 背景 + +Mpx2RN 当前对 CSS 简写属性的支持主要分为两类: + +1. `