55fab1776a
- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口 - 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动 - 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘 - 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口 - 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录 - 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
/**
|
|
* styles.css 的声明级层叠求值器。
|
|
*
|
|
* jsdom 不加载 styles.css,所以"某个声明最终生效的是哪一条"只能在声明上验;
|
|
* 但只读"第一条匹配到的规则"会被后面的同权重规则悄悄顶掉(例如 `.game-approval-dialog`
|
|
* 在 8 千行以后又声明了一次 `width`),所以这里按真实层叠
|
|
* (媒体查询是否命中 -> 特异性 -> 源码顺序)算出最终生效的值。
|
|
*/
|
|
import { expect } from 'vitest';
|
|
|
|
export type StyleRule = {
|
|
selectors: string[];
|
|
declarations: Map<string, string>;
|
|
media: string | null;
|
|
order: number;
|
|
};
|
|
|
|
export function parseStyleSheet(css: string): StyleRule[] {
|
|
const rules: StyleRule[] = [];
|
|
const source = css.replace(/\/\*[\s\S]*?\*\//gu, (comment) =>
|
|
comment.replace(/[^\n]/gu, ' '),
|
|
);
|
|
|
|
const pushDeclarationBlock = (
|
|
selectorText: string,
|
|
body: string,
|
|
media: string | null,
|
|
) => {
|
|
const selectors = splitSelectorList(selectorText);
|
|
if (selectors.length === 0) {
|
|
return;
|
|
}
|
|
const declarations = new Map<string, string>();
|
|
for (const chunk of body.split(';')) {
|
|
const separator = chunk.indexOf(':');
|
|
if (separator < 0) {
|
|
continue;
|
|
}
|
|
const property = chunk.slice(0, separator).trim();
|
|
const value = chunk
|
|
.slice(separator + 1)
|
|
.trim()
|
|
.replace(/\s+/gu, ' ');
|
|
if (property) {
|
|
declarations.set(property, value);
|
|
}
|
|
}
|
|
rules.push({ selectors, declarations, media, order: rules.length });
|
|
};
|
|
|
|
const readBlock = (text: string, start: number) => {
|
|
let depth = 1;
|
|
let index = start;
|
|
while (index < text.length && depth > 0) {
|
|
if (text[index] === '{') {
|
|
depth += 1;
|
|
} else if (text[index] === '}') {
|
|
depth -= 1;
|
|
}
|
|
index += 1;
|
|
}
|
|
return { body: text.slice(start, index - 1), end: index };
|
|
};
|
|
|
|
let cursor = 0;
|
|
let buffer = '';
|
|
while (cursor < source.length) {
|
|
const char = source[cursor]!;
|
|
if (char === '{') {
|
|
const prelude = buffer.trim().replace(/\s+/gu, ' ');
|
|
buffer = '';
|
|
const { body, end } = readBlock(source, cursor + 1);
|
|
cursor = end;
|
|
if (prelude.startsWith('@media')) {
|
|
// 媒体查询体内只陈述"顶层选择器 + 声明",这里就够了:查询用的选择器都在顶层。
|
|
let inner = 0;
|
|
let innerBuffer = '';
|
|
while (inner < body.length) {
|
|
if (body[inner] === '{') {
|
|
const innerSelector = innerBuffer.trim().replace(/\s+/gu, ' ');
|
|
innerBuffer = '';
|
|
const { body: innerBody, end: innerEnd } = readBlock(
|
|
body,
|
|
inner + 1,
|
|
);
|
|
pushDeclarationBlock(innerSelector, innerBody, prelude);
|
|
inner = innerEnd;
|
|
continue;
|
|
}
|
|
innerBuffer += body[inner];
|
|
inner += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (prelude.startsWith('@')) {
|
|
continue;
|
|
}
|
|
pushDeclarationBlock(prelude, body, null);
|
|
continue;
|
|
}
|
|
if (char === '}') {
|
|
buffer = '';
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
buffer += char;
|
|
cursor += 1;
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
export function splitSelectorList(selectorText: string): string[] {
|
|
const selectors: string[] = [];
|
|
let depth = 0;
|
|
let current = '';
|
|
for (const char of selectorText) {
|
|
if (char === '(' || char === '[') {
|
|
depth += 1;
|
|
} else if (char === ')' || char === ']') {
|
|
depth -= 1;
|
|
}
|
|
if (char === ',' && depth === 0) {
|
|
if (current.trim()) {
|
|
selectors.push(current.trim().replace(/\s+/gu, ' '));
|
|
}
|
|
current = '';
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
if (current.trim()) {
|
|
selectors.push(current.trim().replace(/\s+/gu, ' '));
|
|
}
|
|
return selectors;
|
|
}
|
|
|
|
/** 特异性:只区分 id / 类(含属性与伪类)/ 元素,够排序本文件里的选择器。 */
|
|
export function specificity(selector: string): number {
|
|
let rest = selector;
|
|
let classLike = 0;
|
|
// `:has(...)` 的权重按它参数里最高的那条选择器算,这里等价于再加一个类。
|
|
const hasParts = rest.match(/:has\([^)]*\)/gu) ?? [];
|
|
for (const part of hasParts) {
|
|
classLike += 1;
|
|
const inner = Math.max(
|
|
...splitSelectorList(part.slice(5, -1)).map((one) => specificity(one)),
|
|
);
|
|
classLike += Math.floor(inner / 100) % 100;
|
|
}
|
|
rest = rest.replace(/:has\([^)]*\)/gu, ' ');
|
|
const ids = (rest.match(/#[\w-]+/gu) ?? []).length;
|
|
classLike += (rest.match(/\.[\w-]+/gu) ?? []).length;
|
|
classLike += (rest.match(/\[[^\]]*\]/gu) ?? []).length;
|
|
classLike += (rest.match(/:(?!:)[\w-]+/gu) ?? []).length;
|
|
const types =
|
|
(rest.match(/(?:^|[\s>+~])([a-zA-Z][\w-]*)/gu) ?? []).length +
|
|
(rest.match(/::[\w-]+/gu) ?? []).length;
|
|
return ids * 10000 + classLike * 100 + types;
|
|
}
|
|
|
|
export function mediaMatches(media: string, viewportWidth: number): boolean {
|
|
const conditions = media.match(/\((?:min|max)-width:\s*\d+px\)/gu) ?? [];
|
|
if (conditions.length === 0) {
|
|
// 只认识宽度条件:命中了别的媒体特性(例如 prefers-reduced-motion)就说明这条规则
|
|
// 的生效与否不是本用例能判定的,直接报错,避免悄悄算错一个几何值。
|
|
throw new Error(`测试求值器不认识这个媒体查询:${media}`);
|
|
}
|
|
for (const condition of conditions) {
|
|
const parsed = /\((min|max)-width:\s*(\d+)px\)/u.exec(condition)!;
|
|
const limit = Number(parsed[2]);
|
|
if (parsed[1] === 'min' && viewportWidth < limit) {
|
|
return false;
|
|
}
|
|
if (parsed[1] === 'max' && viewportWidth > limit) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 按层叠算出元素最终生效的声明。
|
|
* `elementSelectors` 是这个元素在 DOM 上会命中的全部选择器(含媒体查询里那几条)。
|
|
*/
|
|
export function resolveDeclarations(
|
|
rules: StyleRule[],
|
|
elementSelectors: readonly string[],
|
|
viewportWidth: number,
|
|
): Map<string, string> {
|
|
const winners = new Map<string, { value: string; rank: [number, number] }>();
|
|
for (const rule of rules) {
|
|
const matched = rule.selectors
|
|
.filter((selector) => elementSelectors.includes(selector))
|
|
.map((selector) => specificity(selector));
|
|
if (matched.length === 0) {
|
|
continue;
|
|
}
|
|
if (rule.media && !mediaMatches(rule.media, viewportWidth)) {
|
|
continue;
|
|
}
|
|
const rank: [number, number] = [Math.max(...matched), rule.order];
|
|
for (const [property, value] of rule.declarations) {
|
|
const current = winners.get(property);
|
|
if (
|
|
!current ||
|
|
rank[0] > current.rank[0] ||
|
|
(rank[0] === current.rank[0] && rank[1] > current.rank[1])
|
|
) {
|
|
winners.set(property, { value, rank });
|
|
}
|
|
}
|
|
}
|
|
return new Map(
|
|
Array.from(winners, ([property, winner]) => [property, winner.value]),
|
|
);
|
|
}
|
|
|
|
export function declaration(
|
|
declarations: Map<string, string>,
|
|
property: string,
|
|
): string {
|
|
const value = declarations.get(property);
|
|
expect(value, `缺少生效声明 ${property}`).toBeDefined();
|
|
return value!;
|
|
}
|