资源工作台钉住的标题栏移出画布缩放层:整行文字不再发虚
- 钉在视口上的栏目标题栏(栏目名 / 计数徽标 / 资源总览入口)改挂场景根,成为带 scale() 的 .game-resource-book-scene-world 的兄弟节点,不再用 scale(1 / s) 抵消 world;总览态的栏目标题栏仍留在 world 里与缩略卡对齐 - index.tsx 抽出 ResourceBookScene.renderTitlebar(placement.pinned) 一处产出两种宿主,删除只服务抵消变换的 titlebarTransform 与 safeViewport/safeMainViewport 中间量 - resourceBookController 按节点自己的坐标系换算:world 内用 world 缩放,world 外(钉住标题栏)用 1;新增 nodeWorldTransform,readWorld 补 element 输出,nodeWorld 贯穿 cleanRect / FLIP 反向 transform / worldScale - 更新 resourceBookFlipValues 与 readOrigin 的口径注释:translate 单位是宿主自身坐标系,不再假定所有宿主都在 world 里 - appSurface 用例改钉新不变量:钉住的标题栏内联 transform 必须为空、不在 world 内、父节点为场景根,卡片必须仍在 world 内;并说明标题栏宿主不再跨视图同一节点、转场仍由控制器按 key 记录的 First 屏幕矩形驱动 - resourceBookController 用例新增「屏幕坐标系的宿主不被 world 缩放除」,并保留 world 内卡片仍按缩放除的对照 - 技术方案 2026-09-05 段落同步新结构;pitfalls 增记取证(截图墨色峰值 98.6 vs 同色正文 91.7、点阵周期反推 dpr=1)、本机复现尝试与结论、三处变异验证和真机判据
This commit is contained in:
@@ -1154,10 +1154,48 @@ function ResourceBookScene({
|
||||
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const safeViewport = normalizeResourceBookViewport(viewport);
|
||||
const safeMainViewport = normalizeResourceBookViewport(mainViewport);
|
||||
const sceneViewport =
|
||||
resourceBookState.view === 'main' ? safeMainViewport : safeViewport;
|
||||
const sceneViewport = normalizeResourceBookViewport(
|
||||
resourceBookState.view === 'main' ? mainViewport : viewport,
|
||||
);
|
||||
|
||||
/**
|
||||
* 栏目标题栏宿主:总览态的栏目标题栏与子画布态钉在视口上的那一条共用同一个 DOM 形状。
|
||||
*
|
||||
* `pinned` 决定它挂在哪个坐标系里,这条选择就是**文字清晰度**的开关:
|
||||
* - 总览态(`pinned: false`):缩略卡本身活在 `.game-resource-book-main-world` 的缩放层里,
|
||||
* 标题栏必须在同一个缩放坐标系里,才能严丝合缝地叠在缩略卡的标题位上;
|
||||
* - 子画布态(`pinned: true`):这一条钉在视口上,**不挂进带 `scale()` 的 world**。挂在 world 里
|
||||
* 就得给标题栏自己加 `scale(1 / s)` 反向抵消才能回到 1:1,而反向缩放只保证几何:整条标题栏的
|
||||
* 文字(栏目名、计数徽标、`资源总览` 入口)都落在被缩放的祖先里,栅格化与合成层由浏览器决定,
|
||||
* 一旦这一层被提升或复用旧纹理,文字就会整体发虚(栏目名与计数深浅不同也一起发虚)。
|
||||
* 钉住的那一层本来就活在屏幕坐标系里,直接挂场景根下即可,永远是 1:1。
|
||||
*/
|
||||
const renderTitlebar = (
|
||||
group: ResourceBookSceneCategoryPlan,
|
||||
placement: { pinned: boolean; rect?: ResourceBookOverviewRect },
|
||||
) => {
|
||||
const style: CSSProperties = placement.pinned
|
||||
? { left: 0, top: 0, width: '100%', opacity: 1 }
|
||||
: {
|
||||
left: placement.rect?.left ?? 0,
|
||||
top: placement.rect?.top ?? 0,
|
||||
width: `${placement.rect?.width ?? 280}px`,
|
||||
opacity: group.titlebarOpacity,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`game-resource-book-scene-titlebar${placement.pinned ? ' is-active' : ''}`}
|
||||
data-resource-book-category={group.category}
|
||||
style={style}
|
||||
>
|
||||
<ResourceBookTitleBar
|
||||
category={group.category}
|
||||
countLabel={`${group.resourceCount} 项`}
|
||||
onCollapse={placement.pinned ? onCollapse : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
// 共享 SelectionOverlay 的框选描边/底色 token 只声明在 `.genarrative-image-canvas`
|
||||
@@ -1180,47 +1218,20 @@ function ResourceBookScene({
|
||||
}
|
||||
>
|
||||
{plan.map((group) => {
|
||||
const active = group.titlebarActive;
|
||||
const rect = overviewRects.get(group.category);
|
||||
const titleLayout = active
|
||||
? { left: 0, top: 0, width: '100%', opacity: 1 }
|
||||
: {
|
||||
left: rect?.left ?? 0,
|
||||
top: rect?.top ?? 0,
|
||||
width: `${rect?.width ?? 280}px`,
|
||||
opacity: group.titlebarOpacity,
|
||||
};
|
||||
const titlebarTransform = active
|
||||
? `translate(${-safeViewport.x / safeViewport.scale}px, ${-safeViewport.y / safeViewport.scale}px) scale(${1 / safeViewport.scale})`
|
||||
: undefined;
|
||||
return (
|
||||
<Fragment key={group.category}>
|
||||
{/*
|
||||
展开态的真实栏目不出标题栏:那一页是**一张平铺画布**,只有 `all` 那一条钉在
|
||||
视口上的标题栏(见 `ResourceBookSceneCategoryPlan.titlebar`)。
|
||||
视口上的标题栏(见 `ResourceBookSceneCategoryPlan.titlebar`);钉住的那一条只在
|
||||
场景根下渲染一次(见下方 `renderTitlebar(group, { pinned: true })`),
|
||||
world 里只留总览态的栏目标题栏。
|
||||
*/}
|
||||
{group.titlebar ? (
|
||||
<div
|
||||
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
|
||||
data-resource-book-category={group.category}
|
||||
style={
|
||||
{
|
||||
left: `${titleLayout.left}px`,
|
||||
top: `${titleLayout.top}px`,
|
||||
width: titleLayout.width,
|
||||
opacity: titleLayout.opacity,
|
||||
transform: titlebarTransform,
|
||||
transformOrigin: active ? '0 0' : undefined,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ResourceBookTitleBar
|
||||
category={group.category}
|
||||
countLabel={`${group.resourceCount} 项`}
|
||||
onCollapse={active ? onCollapse : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{group.titlebar && !group.titlebarActive
|
||||
? renderTitlebar(group, {
|
||||
pinned: false,
|
||||
rect: overviewRects.get(group.category),
|
||||
})
|
||||
: null}
|
||||
{group.cards.map((card) => {
|
||||
const expanded = card.presentation === 'child';
|
||||
const cardVisible =
|
||||
@@ -1281,6 +1292,15 @@ function ResourceBookScene({
|
||||
})}
|
||||
{worldOverlay}
|
||||
</div>
|
||||
{/*
|
||||
钉在视口上的栏目标题栏:**world 之外**的兄弟节点,文字按 CSS 像素 1:1 栅格化。
|
||||
它必须排在 world 之后(同层里后画的压在上面),才能盖住世界层里的卡片。
|
||||
*/}
|
||||
{plan.map((group) =>
|
||||
group.titlebar && group.titlebarActive
|
||||
? renderTitlebar(group, { pinned: true })
|
||||
: null,
|
||||
)}
|
||||
{overlay}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -105,8 +105,8 @@ export function resourceBookValidMotionRect(
|
||||
|
||||
/**
|
||||
* FLIP 反向 transform。transform-origin 固定 0 0,缩放围绕宿主节点自身的原点进行,
|
||||
* 所以位置需要按原点偏移换算;translate 的数值单位是宿主节点所在坐标系(world 局部
|
||||
* 空间),屏幕位移要先除以 world 的缩放。
|
||||
* 所以位置需要按原点偏移换算;translate 的数值单位是**宿主节点自己的坐标系**:world 里的
|
||||
* 卡片按 world 局部空间(屏幕位移除以 world 缩放),钉住的栏目标题栏按屏幕空间(比例 1)。
|
||||
*/
|
||||
export function resourceBookFlipValues(
|
||||
first: ResourceBookMotionRect,
|
||||
@@ -166,7 +166,7 @@ function readVisualOpacity(host: HTMLElement, fallback: number) {
|
||||
|
||||
/**
|
||||
* 宿主节点自身原点的屏幕位置。卡片包装节点是 0x0,原点就是它的矩形左上角;标题栏是
|
||||
* 有尺寸的节点,激活时还带一层抵消 world 的内联 transform,需要临时摘掉再量。
|
||||
* 有尺寸的节点,内联 transform 若是上一段动画留下的残留,需要临时摘掉再量。
|
||||
*/
|
||||
function readOrigin(host: HTMLElement): ResourceBookMotionOrigin {
|
||||
const base = host.style.transform;
|
||||
@@ -185,13 +185,24 @@ type WorldTransform = {
|
||||
origin: ResourceBookMotionOrigin;
|
||||
};
|
||||
|
||||
type SceneWorldTransform = WorldTransform & { element: HTMLElement | null };
|
||||
|
||||
/**
|
||||
* 不在 world 里的节点(钉在视口上的栏目标题栏)所在坐标系:它本身就在屏幕坐标系里,
|
||||
* 屏幕位移不需要除以 world 缩放,几何换算走恒等。
|
||||
*/
|
||||
const SCREEN_SPACE_TRANSFORM: WorldTransform = {
|
||||
scale: 1,
|
||||
origin: { left: 0, top: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* world 变换(平移 + 缩放,transform-origin 0 0)。宿主节点的几何基准统一存成
|
||||
* world 局部坐标,平移和缩放就不会把"上一次的干净位置"变成过期的屏幕坐标。
|
||||
*/
|
||||
function readWorld(root: HTMLElement): WorldTransform {
|
||||
function readWorld(root: HTMLElement): SceneWorldTransform {
|
||||
const world = root.querySelector<HTMLElement>(WORLD_SELECTOR);
|
||||
if (!world) return { scale: 1, origin: { left: 0, top: 0 } };
|
||||
if (!world) return { element: null, scale: 1, origin: { left: 0, top: 0 } };
|
||||
const rect = world.getBoundingClientRect();
|
||||
const transform = getComputedStyle(world).transform;
|
||||
const match = /^matrix(?:3d)?\(([^)]+)\)$/.exec(transform);
|
||||
@@ -199,7 +210,19 @@ function readWorld(root: HTMLElement): WorldTransform {
|
||||
? Number.parseFloat(match[1]!.split(',')[0] ?? '')
|
||||
: Number.NaN;
|
||||
const scale = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
return { scale, origin: { left: rect.left, top: rect.top } };
|
||||
return { element: world, scale, origin: { left: rect.left, top: rect.top } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点自己的坐标系。钉住的栏目标题栏是 world 的**兄弟**节点(见 `ResourceBookScene`),
|
||||
* 它不经 world 缩放,必须按屏幕坐标系换算,否则 FLIP 的反向平移会被 world 缩放除错,
|
||||
* 动画终点会偏移。
|
||||
*/
|
||||
function nodeWorldTransform(
|
||||
host: HTMLElement,
|
||||
world: SceneWorldTransform,
|
||||
): WorldTransform {
|
||||
return world.element?.contains(host) ? world : SCREEN_SPACE_TRANSFORM;
|
||||
}
|
||||
|
||||
function toWorldRect(
|
||||
@@ -430,13 +453,15 @@ export function createResourceBookTransitionController() {
|
||||
if (!existing) records.set(node.key, record);
|
||||
record.host = node.host;
|
||||
record.box = node.box;
|
||||
// 每个节点用自己的坐标系:world 里的卡片按 world 缩放换算,钉住的栏目标题栏按屏幕换算。
|
||||
const nodeWorld = nodeWorldTransform(node.host, world);
|
||||
if (options.frozenKeys?.has(node.key)) {
|
||||
// 拖拽中的卡片只跟指针走:不参与布局 FLIP,但保持基准几何最新,
|
||||
// 落位提交后不会把卡片拉回拖拽前的位置。
|
||||
cancelRecord(record);
|
||||
record.signature = signature;
|
||||
record.targetOpacity = targetOpacity;
|
||||
record.cleanRect = toWorldRect(readRect(node.box), world);
|
||||
record.cleanRect = toWorldRect(readRect(node.box), nodeWorld);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -461,7 +486,7 @@ export function createResourceBookTransitionController() {
|
||||
record.signature = signature;
|
||||
record.targetOpacity = targetOpacity;
|
||||
if (!hadAnimation) {
|
||||
record.cleanRect = toWorldRect(readRect(node.box), world);
|
||||
record.cleanRect = toWorldRect(readRect(node.box), nodeWorld);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -476,7 +501,7 @@ export function createResourceBookTransitionController() {
|
||||
cancelRecord(record);
|
||||
record.signature = signature;
|
||||
record.targetOpacity = targetOpacity;
|
||||
record.cleanRect = toWorldRect(readRect(node.box), world);
|
||||
record.cleanRect = toWorldRect(readRect(node.box), nodeWorld);
|
||||
if (suppressed) continue;
|
||||
// 转场用 begin 记录的 First;动画中途重基用"此刻像素";空闲布局变化用上一次的干净矩形。
|
||||
const first =
|
||||
@@ -484,7 +509,7 @@ export function createResourceBookTransitionController() {
|
||||
(hadAnimation
|
||||
? visual
|
||||
: previousCleanRect
|
||||
? fromWorldRect(previousCleanRect, world)
|
||||
? fromWorldRect(previousCleanRect, nodeWorld)
|
||||
: visual);
|
||||
startMotion(record, {
|
||||
first,
|
||||
@@ -493,7 +518,7 @@ export function createResourceBookTransitionController() {
|
||||
snapshotEntry && !hadAnimation ? snapshotEntry.opacity : startOpacity,
|
||||
endOpacity: targetOpacity,
|
||||
duration,
|
||||
worldScale: world.scale,
|
||||
worldScale: nodeWorld.scale,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -785,14 +785,24 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(documentCardBefore).not.toBeNull();
|
||||
expect(documentTitlebarBefore).not.toBeNull();
|
||||
await openResourceBookCategory('待归类');
|
||||
// 真实元素 FLIP:卡片和标题栏在总览与子画布之间是同一批 DOM 节点,
|
||||
// 转场不引入克隆快照层。
|
||||
// 真实元素 FLIP:卡片在总览与子画布之间是同一批 DOM 节点,转场不引入克隆快照层。
|
||||
expect(documentCardBefore!.isConnected).toBe(true);
|
||||
expect(documentCardBefore!.closest('[data-resource-book-view]')).toBe(
|
||||
document.querySelector('[data-resource-book-view="child"]'),
|
||||
);
|
||||
expect(documentTitlebarBefore!.isConnected).toBe(true);
|
||||
expect(documentTitlebarBefore!.classList.contains('is-active')).toBe(true);
|
||||
/**
|
||||
* 钉在视口上的标题栏是**屏幕坐标系里的另一份宿主**:它不挂进带 `scale()` 的 world
|
||||
* (见 `ResourceBookScene.renderTitlebar`),所以进入栏目时总览那一份卸下、钉住的那一份挂上。
|
||||
* 转场仍然是真实元素的 FLIP:控制器按 key 记下 First 屏幕矩形,动画在新宿主上从该矩形回到静止位
|
||||
* (见 `resourceBookController.play` 的 `remounted` 分支),没有克隆层。
|
||||
*/
|
||||
const pinnedTitlebar = document.querySelector<HTMLElement>(
|
||||
'[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active[data-resource-book-category="unclassified"]',
|
||||
);
|
||||
expect(pinnedTitlebar).not.toBeNull();
|
||||
expect(
|
||||
pinnedTitlebar!.closest('.game-resource-book-scene-world'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.game-resource-book-transition-layer'),
|
||||
).toBeNull();
|
||||
@@ -856,13 +866,28 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
const childTitlebar = document.querySelector<HTMLElement>(
|
||||
'[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active',
|
||||
);
|
||||
expect(childTitlebar?.style.transform).toContain('scale(');
|
||||
expect(childTitlebar).not.toBeNull();
|
||||
const childScene = childCard?.closest<HTMLElement>(
|
||||
'.game-resource-book-scene',
|
||||
);
|
||||
const childSceneWorld = childScene?.querySelector<HTMLElement>(
|
||||
'.game-resource-book-scene-world',
|
||||
);
|
||||
/**
|
||||
* 钉在视口上的那一行(栏目名 / 计数 / `资源总览`)必须活在**屏幕坐标系**里:
|
||||
* 它是缩放层 world 的兄弟节点,自身不带任何抵消缩放的 transform。
|
||||
* 反过来(挂进 world 再用 `scale(1 / s)` 抵消)整条标题栏的文字都要在被缩放的祖先里栅格化,
|
||||
* 会出现整行文字一起发虚。这里把结构钉死,避免以后又被搬回 world。
|
||||
*/
|
||||
expect(childTitlebar!.style.transform).toBe('');
|
||||
expect(
|
||||
childTitlebar!.closest('.game-resource-book-scene-world'),
|
||||
).toBeNull();
|
||||
expect(childTitlebar!.parentElement).toBe(childScene);
|
||||
// world 仍然只包画布内容:卡片留在缩放层里(缩略卡与场景卡共用同一个坐标系)。
|
||||
expect(childCard!.closest('.game-resource-book-scene-world')).toBe(
|
||||
childSceneWorld,
|
||||
);
|
||||
const childSceneTransformBeforeZoom = childSceneWorld?.style.transform;
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -72,7 +72,7 @@ function installAnimateMock() {
|
||||
return flights;
|
||||
}
|
||||
|
||||
function fixture(worldScale = 1) {
|
||||
function fixture(worldScale = 1, options: { pinnedTitlebar?: boolean } = {}) {
|
||||
const manager = document.createElement('div');
|
||||
manager.className = 'game-resource-book-manager';
|
||||
manager.innerHTML = `<div class="game-resource-book-scene">
|
||||
@@ -82,6 +82,11 @@ function fixture(worldScale = 1) {
|
||||
<div class="game-resource-card" data-resource-card-id="one"></div>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
options.pinnedTitlebar
|
||||
? '<div class="game-resource-book-scene-titlebar is-active" data-resource-book-category="unclassified"></div>'
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
document.body.append(manager);
|
||||
const world = manager.querySelector<HTMLElement>(
|
||||
@@ -92,13 +97,23 @@ function fixture(worldScale = 1) {
|
||||
)!;
|
||||
const cardBox = manager.querySelector<HTMLElement>('.game-resource-card')!;
|
||||
const titleHost = manager.querySelector<HTMLElement>(
|
||||
'.game-resource-book-scene-titlebar',
|
||||
'.game-resource-book-scene-world .game-resource-book-scene-titlebar',
|
||||
)!;
|
||||
const pinnedTitleHost = manager.querySelector<HTMLElement>(
|
||||
'.game-resource-book-scene > .game-resource-book-scene-titlebar',
|
||||
);
|
||||
const rects = new Map<Element, MotionRect>();
|
||||
const setRect = (element: Element, rect: MotionRect) => {
|
||||
rects.set(element, rect);
|
||||
};
|
||||
for (const element of [manager, world, cardHost, cardBox, titleHost]) {
|
||||
for (const element of [
|
||||
manager,
|
||||
world,
|
||||
cardHost,
|
||||
cardBox,
|
||||
titleHost,
|
||||
...(pinnedTitleHost ? [pinnedTitleHost] : []),
|
||||
]) {
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() =>
|
||||
toDomRect(rects.get(element) ?? { left: 0, top: 0, width: 0, height: 0 }),
|
||||
);
|
||||
@@ -130,6 +145,7 @@ function fixture(worldScale = 1) {
|
||||
cardHost,
|
||||
cardBox,
|
||||
titleHost,
|
||||
pinnedTitleHost,
|
||||
setRect,
|
||||
signatures,
|
||||
};
|
||||
@@ -301,7 +317,7 @@ describe('resource book real element FLIP', () => {
|
||||
it('still animates the active titlebar back to the overview stack', () => {
|
||||
const flights = installAnimateMock();
|
||||
const { manager, cardBox, titleHost, setRect, signatures } = fixture();
|
||||
// 子画布静止态:活动标题栏用抵消 world 的内联 transform 钉在屏幕上。
|
||||
// 子画布静止态:标题栏的屏幕矩形与 world 局部矩形不一致(world 有平移/缩放)。
|
||||
vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() =>
|
||||
titleHost.style.transform
|
||||
? toDomRect({ left: 0, top: 0, width: 560, height: 84 })
|
||||
@@ -321,6 +337,53 @@ describe('resource book real element FLIP', () => {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('flips a pinned titlebar in screen space, not in world space', () => {
|
||||
const flights = installAnimateMock();
|
||||
// 钉在视口上的标题栏是缩放层 world 的兄弟节点(见 ResourceBookScene):它不在 world 坐标系里,
|
||||
// 反向平移不能再除以 world 缩放,否则动画终点会被缩放除错。
|
||||
const { manager, cardBox, pinnedTitleHost, setRect, signatures } = fixture(
|
||||
2,
|
||||
{
|
||||
pinnedTitlebar: true,
|
||||
},
|
||||
);
|
||||
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
||||
setRect(pinnedTitleHost!, { left: 40, top: 30, width: 560, height: 84 });
|
||||
const controller = createResourceBookTransitionController();
|
||||
const token = controller.begin(manager);
|
||||
|
||||
// 收起回总览:钉住的那一条换成总览矩形(同位、更窄更矮)。
|
||||
setRect(pinnedTitleHost!, { left: 0, top: 0, width: 800, height: 42 });
|
||||
controller.play(manager, token, () => undefined, signatures);
|
||||
|
||||
const flight = flights.find((entry) => entry.host === pinnedTitleHost)!;
|
||||
// world 缩放为 2:世界层里的节点会被除以 2;屏幕坐标系里的宿主必须原样保留 (40, 30)。
|
||||
expect(flight.frames[0]!.transform).toBe(
|
||||
'translate(40px, 30px) scale(0.7, 2)',
|
||||
);
|
||||
expect(flight.frames[1]!.transform).toBe('none');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('still divides a world-space card translation by the world scale', () => {
|
||||
const flights = installAnimateMock();
|
||||
const { manager, cardHost, cardBox, setRect, signatures } = fixture(2);
|
||||
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
||||
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
|
||||
const controller = createResourceBookTransitionController();
|
||||
const token = controller.begin(manager);
|
||||
|
||||
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
||||
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
|
||||
controller.play(manager, token, () => undefined, signatures);
|
||||
|
||||
const flight = flights.find((entry) => entry.host === cardHost)!;
|
||||
expect(flight.frames[0]!.transform).toBe(
|
||||
'translate(-50px, -25px) scale(0.511, 0.5)',
|
||||
);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('fades a titlebar out in place while entering a category', () => {
|
||||
const flights = installAnimateMock();
|
||||
const { manager, cardBox, titleHost, setRect, signatures } = fixture();
|
||||
|
||||
@@ -5380,3 +5380,13 @@
|
||||
- **处理**:把 CAS 判据收窄到契约面——新增 `manifestCasFingerprint`(`{ assets, versions }`),同 revision 下三态:判据面不同 → 仍 `revision-conflict`(真实的漏推 revision 必须继续可见,不许被吞掉);判据面相同且整份清单相同 → `duplicate`;判据面相同、只有非 CAS 账目状态(`preview` / 任务进度 / 项目名)不同 → `accepted`,revision **不推也不退**,让内容真正落地。同类簿记写点(`record_command_run`、`update_manifest_task_status_at`、`rename_local_game_project`、Godot 根校准)同样不推 revision,收窄判据面后不再产生用户可见拒收。
|
||||
- **验证**:新增用例 `adopts same-revision bookkeeping changes instead of rejecting them`(同 revision 只有 `preview` 从 stopped 变 running ⇒ 必须 `accepted`、`projectManifestMergeRejectionDecision` 为 null、revision 仍为 4)与对照钉子 `still fails closed when the protected assets change under the same revision`(同 revision 多出一条资产 ⇒ 必须仍 `revision-conflict`)。变异验证:把判据改回整份 JSON 指纹 → 新用例以 `expected 'revision-conflict' to be 'accepted'` 失败(正是用户看到的那条决策),恢复后转绿。定向:`projectResourceLiveUpdateModel` 17 passed、`workspaceLauncherManifestMerge` 5 passed、appSurface 413 passed、`npm run typecheck` exit 0、`npm run check:encoding` 4399 files passed、`git diff --check` 干净。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/App.tsx`(supervisor 配对读)、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`。
|
||||
|
||||
## 2026-09-12 资源工作台顶部那一行发虚:钉住的标题栏不该活在画布缩放层里
|
||||
|
||||
- **现象**:资源管理页顶部那一条栏目标题栏(栏目名「待归类」+ 计数「2 项」+「资源总览」入口)肉眼比页面其它文字糊,用户截图把左(栏目名)右(计数 + 入口)两块都框了出来;同一行里深浅不同、字号不同的三段文字一起发虚。
|
||||
- **取证**:① 截图里那一行墨色峰值 luminance 98.6,而同一张图里**同一个 token 色**(`--platform-text-base` `#6f5848`)的正文峰值 91.7 ⇒ 那一行的字形边缘确实比同色文字更软(不是"颜色浅"三个字能解释的)。② 用截图里场景点阵的周期反推 dpr:`18px` 网格量到 18 设备像素 ⇒ dpr=1、无页面缩放;标题栏仍是 13px `<strong>` + 11px 计数。③ 用**真实 `styles.css` + `theme.css` + 真实 DOM 形状**在本机 Edge(有头带 LCD 彩边、无头软件光栅与 GPU 光栅各一遍)复现:world `translate(x,y) scale(s)` + 标题栏 `translate(-x/s,-y/s) scale(1/s)` 抵消之后,与"完全不挂 world"的同一行**逐像素相同**(ink / coverage / 最暗值 / LCD 彩边数完全一致),`s=1`、`s=1.1`、有/无卡片 `translate3d` 合成层、有/无带 `backdrop-filter` 的缩放胶囊都不改变这个结论。
|
||||
- **原因(代码层)**:这一行是**唯一**活在带 `scale()` 的 world 里的文本,它的 1:1 完全靠 `scale(1 / s)` 反向抵消换来(旧 `index.tsx` 把抵消 transform 内联在 `.game-resource-book-scene-titlebar` 上)。反向缩放只保证几何,栅格化与合成层仍由浏览器决定:实测把同一个节点提升成独立合成层(`will-change: transform`)后,LCD 彩边像素从 90 直接掉到 0,整行转灰度 AA。也就是说这一行的清晰度押在了"浏览器恰好按合成矩阵 1:1 光栅化"上——这是环境相关的(本机复现不出来,恰好说明它有环境依赖),而结构上它本来就不该在画布缩放层里:钉在视口上的 chrome 与画布内容不是同一个坐标系。
|
||||
- **处理**:`ResourceBookScene` 把**钉住那一条**标题栏改挂场景根(`.game-resource-book-scene-world` 的兄弟节点),不再带任何抵消 transform;总览态的栏目标题栏仍留在 world 里跟缩略卡对齐。`resourceBookController` 按"节点是否在 world 内"选择坐标系缩放(world 外 = 1),否则 FLIP 的反向平移会被 world 缩放除错。转场仍是真实元素 FLIP:控制器按 key 记 First 屏幕矩形,动画在**新宿主**上从该矩形回到静止位(`remounted` 分支),无克隆层;代价是标题栏宿主不再跨视图保持同一个 DOM 节点(卡片仍然保持)。
|
||||
- **验证**:appSurface 钉住新不变量——钉住的标题栏内联 `transform` 必须为空、`closest('.game-resource-book-scene-world')` 必须为 null、父节点是场景根,同时卡片必须仍在 world 内;`resourceBookController.test.ts` 新增「屏幕坐标系的宿主不被 world 缩放除」(worldScale=2 时 `translate(40px, 30px)` 而不是 `(20px, 15px)`)。变异验证三处:把标题栏搬回 world → `expected <div …> to be null`;恢复抵消 transform → `expected 'translate(-160px, 158px) scale(1)' to be ''`;把 per-node 缩放退回 `world.scale` → `expected 'translate(20px, 15px) …' to be 'translate(40px, 30px) …'`,三处都如期变红。定向:`resourceBook*` 76 passed、appSurface 413 passed、`npm run typecheck` exit 0、`npm run check:encoding` 4409 files passed、`git diff --check` 干净。**像素级效果本机没有复现出差异**,因此这次修复是"把清晰度从浏览器光栅决策里拿回来"的结构修复,真机判据见下一条。
|
||||
- **真机判据**:那一行的三段文字(栏目名 / 计数 / `资源总览`)与小标题等锐度;Ctrl+滚轮把子画布缩放到 110% / 90% 时,钉住的那一行不再随缩放变化锐度,也不随缩小而变糊。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(`ResourceBookScene.renderTitlebar`)、`apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts`(`nodeWorldTransform`)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、`apps/ai-game-creator-shell/tests/resourceBookController.test.ts`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`。
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
## 2026-09-05 资源画本独立转场
|
||||
|
||||
- `resourceBookModel` 立即提交目标 `main / child + category`,`entering / returning-main` 只表示尚未收尾的视觉过程,不承载待提交的栏目。总览栏目缩略卡片、「所有资源」入口、下一页与资源定位共用分类导航入口;栏目互切直接到达目标,不经过总览(列表卡片本身就在总览上,所以从总览进栏目是先总览后直达)。滚轮不切换栏目。
|
||||
- 转场对**真实元素**做 FLIP,不使用克隆快照层。总览与子画布之间同一张卡是同一个 DOM 节点(同一 React key、同一父节点,只有布局函数在 `resourceBookOverviewCardLayout` 与 `resourceBookChildCardLayout` 之间切换),标题栏同理。`resourceBookController.begin` 在状态提交前按 key 记录 First 屏幕矩形与当前不透明度;提交后的 layout effect 调 `play` 测 Last,给同一个节点加反向 transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,因此不存在交接瞬间的跳变;遮挡关系由真实 DOM 的 z-index/DOM 顺序决定,与静止态一致。
|
||||
- 反向 transform 写在宿主节点自身坐标系:`transform-origin: 0 0`,缩放比取 `First.size / Last.size`,位移按宿主原点偏移换算后除以 `.game-resource-book-scene-world` 的缩放。标题栏激活时内联的"抵消 world"transform 作为 base 与反向 transform 组合,结束帧回到该 base。
|
||||
- 转场对**真实元素**做 FLIP,不使用克隆快照层。总览与子画布之间同一张卡是同一个 DOM 节点(同一 React key、同一父节点,只有布局函数在 `resourceBookOverviewCardLayout` 与 `resourceBookChildCardLayout` 之间切换)。`resourceBookController.begin` 在状态提交前按 key 记录 First 屏幕矩形与当前不透明度;提交后的 layout effect 调 `play` 测 Last,给同一个节点加反向 transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,因此不存在交接瞬间的跳变;遮挡关系由真实 DOM 的 z-index/DOM 顺序决定,与静止态一致。
|
||||
- 钉在视口上的那条栏目标题栏是**例外**:它是 `.game-resource-book-scene-world` 的**兄弟节点**(挂在场景根下),只在子画布态渲染;总览态的栏目标题栏仍在 world 里。原因是文字清晰度——world 带 `scale()`,挂进去就必须给标题栏自己加 `scale(1 / s)` 抵消才能回到 1:1,而反向缩放只保证几何,整条标题栏的文字(栏目名、计数徽标、`资源总览` 入口)仍要在被缩放的祖先里栅格化/合成,一旦那一层被提升或复用旧纹理就会整行发虚。钉住的那一层本来就活在屏幕坐标系里。转场由控制器按 key 记录 First 屏幕矩形、在新宿主上从该矩形回到静止位驱动,仍然是真实元素的 FLIP,只是标题栏宿主不再跨视图保持同一个节点(卡片仍然保持)。
|
||||
- 反向 transform 写在宿主节点自身坐标系:`transform-origin: 0 0`,缩放比取 `First.size / Last.size`,位移按宿主原点偏移换算后除以**宿主自己的坐标系缩放**(world 里的卡片取 `.game-resource-book-scene-world` 的缩放,world 外的钉住标题栏取 1,见 `resourceBookController.nodeWorldTransform`)。
|
||||
- `begin` 之后的所有布局提交都调 `sync`:比较每个节点的布局签名,变化时取消旧动画、重新测量,以**当前视觉矩形**(含运行中的 transform)为起点重基到新的干净终点。转场进行中用剩余时长(`startTime + 420 - now`,下限 80ms),空闲布局变化用完整 420ms。卡片预览尺寸是异步到达的,因此"动画期间甚至动画结束之后"的二次布局变化同样是动画而不是瞬移。窗口/容器尺寸变化与拖拽中的卡片不做布局动画。
|
||||
- 目标不可见的节点(内联 `opacity` 为 0)钉在 First 位置淡出,没有 First 的挂载节点淡入;搜索/排序导致的卸载保留一帧后用同一套淡出撤下,进入栏目期间其他栏目保留挂载淡出,返回总览时源栏目未回到摞上的卡片按子画布布局淡出。转场时长统一 420ms;动画创建失败、零尺寸、容器尺寸变化或 `prefers-reduced-motion` 时不创建动画,也不等待定时器。
|
||||
- `begin` 只记录**当时可见**的节点。不可见节点的屏幕矩形是"看不见的位置":非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,若把它当 First,返回总览时标题栏会从屏幕外飞进来。不可见节点没有 First,只走淡入,位置保持不动;进入栏目方向它们仍然可见,因此照旧钉在原地淡出。同理,卡片只有在提交前可见时才参与几何 FLIP。
|
||||
|
||||
Reference in New Issue
Block a user