Files
suzmii 55fab1776a
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 6m50s
Project CI / Native shell tests (pull_request) Failing after 14m3s
AGC 画布验收问题修复与栏目画布底部工具栏
- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口
- 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动
- 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘
- 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口
- 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录
- 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
2026-09-13 14:07:03 +08:00

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!;
}