Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/mpx2rn/references/rn-style-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ Mpx 在 RN 平台支持 CSS 背景图及渐变背景,框架会自动处理样
**限制与注意事项:**

- **组件限制**:仅 `view` 组件支持除 `background-color` 外的背景相关属性。
- **简写属性**:`background` 简写属性仅支持 `<background-color>`、`<background-image>``<background-repeat>`,不支持 `background-position` 和 `background-size` 简写
- **简写属性**:`background` 简写属性支持 `<background-color>`、`<background-image>``<background-repeat>`,以及用 `/` 分隔的 `<background-position> / <background-size>` 语法
- **背景重复**:`background-repeat` 仅支持 `no-repeat`。
- **多重背景**:不支持多重背景。
- **渐变类型**:仅支持 `linear-gradient()` 线性渐变,不支持 `radial-gradient()`、`conic-gradient()` 等其他渐变类型。
Expand Down Expand Up @@ -497,7 +497,7 @@ Mpx 在 RN 平台支持 CSS 背景图及渐变背景,框架会自动处理样

| 属性 | 值类型 | 说明 | 示例 |
| --- | --- | --- | --- |
| `background` | `<background-color>` \| `<background-image>` \| `<background-repeat>` | 背景简写,不支持 `background-position` 和 `background-size` 简写 | `background: #f5f5f5`;`background: url(https://example.com/bg.png) no-repeat` |
| `background` | `<background-color>` \| `<background-image>` \| `<background-repeat>` \| `<background-position>` / `<background-size>` | 背景简写,支持用 `/` 分隔的背景位置和尺寸 | `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` |
Expand Down
3 changes: 2 additions & 1 deletion docs-vitepress/guide/rn/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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;
```
Expand Down
108 changes: 78 additions & 30 deletions packages/webpack-plugin/lib/platform/style/wx/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() 取值失败
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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
}
}
Expand Down
59 changes: 42 additions & 17 deletions packages/webpack-plugin/lib/runtime/components/react/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = {
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<string, boolean> = {
backgroundImage: true,
backgroundSize: true,
backgroundRepeat: true,
backgroundPosition: true
}
export const TEXT_PROPS_MAP: Record<string, boolean> = {
ellipsizeMode: true,
numberOfLines: true
}
export const DEFAULT_FONT_SIZE = 16
export const HIDDEN_STYLE = {
opacity: 0
Expand All @@ -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<string, boolean> = {
padding: true,
paddingTop: true,
paddingRight: true,
paddingBottom: true,
paddingLeft: true,
borderWidth: true,
borderTopWidth: true,
borderRightWidth: true,
borderBottomWidth: true,
borderLeftWidth: true
}

const safeAreaInsetMap: Record<string, 'top' | 'right' | 'bottom' | 'left'> = {
'safe-area-inset-top': 'top',
Expand All @@ -58,7 +81,11 @@ export function transformBoxSizing (style: Record<string, any> = {}, 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<string, any> | undefined) {
Expand Down Expand Up @@ -153,9 +180,9 @@ export function splitStyle<T extends Record<string, any>> (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'
Expand Down Expand Up @@ -481,13 +508,8 @@ export function useTransformStyle (styleObj: Record<string, any> = {}, { 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())
}
}
Expand All @@ -504,7 +526,7 @@ export function useTransformStyle (styleObj: Record<string, any> = {}, { 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 }))
}
}
Expand Down Expand Up @@ -555,6 +577,9 @@ export function useTransformStyle (styleObj: Record<string, any> = {}, { 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 {
Expand Down Expand Up @@ -639,7 +664,7 @@ export function splitProps<T extends Record<string, any>> (props: T): {
innerProps?: Partial<T>
} {
return groupBy(props, (key) => {
if (TEXT_PROPS_REGEX.test(key)) {
if (hasOwn(TEXT_PROPS_MAP, key)) {
return 'textProps'
} else {
return 'innerProps'
Expand Down
Original file line number Diff line number Diff line change
@@ -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') => ({
Expand Down Expand Up @@ -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; }'
Expand Down
Loading
Loading