主题切换的圆形展开 VT 动画对可运行代码块不生效:代码块直接瞬切,而非被 圆形展开揭示。上一个提交(theme-change 事件同步 setTheme)只解决了快照时序 问题,但真实环境仍不生效。 根因(真实页面逐帧截图 + 像素采样确认):article.post-single 的 animate-page-enter 入场动画用 transform: translateY/scale,因 animation-fill-mode: both 在动画结束后 保留计算值。transform: none 被浏览器序列化为 matrix(1,0,0,1,0,0),仍是「非 none」 的 transform,创建堆叠上下文(stack context)。::view-transition-old/new(root) 顶层 伪元素无法正确覆盖堆叠上下文内部的 CodeMirror/xterm——圆形展开扫过代码块区域时, 伪元素被穿透,代码块的实时 DOM 变化(use_effect 驱动的 setTheme)直接可见,表现为 「直接瞬切,不受 VT 动画控制」。 修复:在 is-theme-transitioning 期间用 !important 清除 .animate-page-enter 的 transform。is-theme-transitioning 在 startViewTransition 之前添加,OLD/NEW 快照 均含此 class,故两快照 transform 都被清为 none,一致,不影响动画效果。 验证: - scripts/vt-real-page-sampler.mjs + vt-xterm-real-sampler.mjs:Playwright 驱动 真实 dx serve 页面,逐帧截图 + 像素采样 - 修复前:代码块在圆形边界外已变 dark(直接瞬切);修复后:代码块随圆形展开 同步变色(lag 与几何距离一致,非瞬切) - 像素追踪:editor 在 t=266ms 变色(圆形到达),bg 在 t=362ms 变色(圆形到达更远点), 时序与圆形几何一致,证明两者都通过 VT 动画变色
210 lines
8.7 KiB
JavaScript
210 lines
8.7 KiB
JavaScript
// 真实页面 xterm 终端区域 VT 采样器。
|
|
//
|
|
// 验证 xterm 终端输出区域(运行后挂载)是否随 VT 圆形展开动画变色。
|
|
// 与编辑器采样器不同:xterm 在用户点「运行」后才挂载,且背景是 inline style。
|
|
//
|
|
// 运行:node scripts/vt-xterm-real-sampler.mjs
|
|
// 前提:make dev 已启动。
|
|
|
|
import { existsSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { homedir } from "node:os";
|
|
import { inflateSync } from "node:zlib";
|
|
|
|
const __require = createRequire(import.meta.url);
|
|
|
|
function loadChromium() {
|
|
const candidates = [];
|
|
const npxCache = join(homedir(), ".npm", "_npx");
|
|
if (existsSync(npxCache)) {
|
|
for (const hash of readdirSync(npxCache)) {
|
|
candidates.push(join(npxCache, hash, "node_modules", "playwright-core"));
|
|
}
|
|
}
|
|
for (const dir of candidates) {
|
|
if (!existsSync(dir)) continue;
|
|
try {
|
|
const pw = __require(dir);
|
|
if (pw?.chromium?.launch) return pw.chromium;
|
|
} catch {}
|
|
}
|
|
throw new Error("playwright not found");
|
|
}
|
|
|
|
function decodePng(buf) {
|
|
let off = 8, w = 0, h = 0, ct = 0;
|
|
const idat = [];
|
|
while (off < buf.length) {
|
|
const len = buf.readUInt32BE(off); off += 4;
|
|
const type = buf.toString("ascii", off, off + 4); off += 4;
|
|
const data = buf.subarray(off, off + len); off += len + 4;
|
|
if (type === "IHDR") { w = data.readUInt32BE(0); h = data.readUInt32BE(4); ct = data[9]; }
|
|
else if (type === "IDAT") idat.push(data);
|
|
else if (type === "IEND") break;
|
|
}
|
|
const inf = inflateSync(Buffer.concat(idat));
|
|
const ch = ct === 6 ? 4 : 3;
|
|
const bpp = ch, stride = w * bpp;
|
|
let prev = Buffer.alloc(stride), io = 0;
|
|
const raw = Buffer.alloc(h * stride);
|
|
for (let y = 0; y < h; y++) {
|
|
const f = inf[io++], line = inf.subarray(io, io + stride); io += stride;
|
|
const out = Buffer.alloc(stride);
|
|
for (let x = 0; x < stride; x++) {
|
|
const c = line[x], l = x >= bpp ? out[x - bpp] : 0, u = prev[x], ul = x >= bpp ? prev[x - bpp] : 0;
|
|
let v;
|
|
switch (f) {
|
|
case 0: v = c; break;
|
|
case 1: v = (c + l) & 0xff; break;
|
|
case 2: v = (c + u) & 0xff; break;
|
|
case 3: v = (c + ((l + u) >> 1)) & 0xff; break;
|
|
case 4: { const pp = l + u - ul, pa = Math.abs(pp-l), pb = Math.abs(pp-u), pc = Math.abs(pp-ul); v = (c + (pa<=pb&&pa<=pc?l:pb<=pc?u:ul)) & 0xff; } break;
|
|
default: throw new Error("f" + f);
|
|
}
|
|
out[x] = v;
|
|
}
|
|
out.copy(raw, y * stride); prev = out;
|
|
}
|
|
return { r: raw[0], g: raw[1], b: raw[2] };
|
|
}
|
|
const hex = ({ r, g, b }) => "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
|
|
async function main() {
|
|
const chromium = loadChromium();
|
|
const browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
|
|
const page = await context.newPage();
|
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
|
|
console.log("[info] navigating to /post/rust");
|
|
await page.goto("http://localhost:8080/post/rust", { waitUntil: "networkidle", timeout: 30000 });
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Set theme to Light (cycle if needed)
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
for (let i = 0; i < 3; i++) {
|
|
const st = await page.evaluate(() => {
|
|
const tl = document.querySelector(".theme-toggle")?.getAttribute("title") || "";
|
|
const dark = document.documentElement.classList.contains("dark");
|
|
return { isSystem: tl.includes("跟随系统"), isDark: dark, label: tl };
|
|
});
|
|
if (!st.isSystem && !st.isDark) break;
|
|
await page.click(".theme-toggle");
|
|
await page.waitForTimeout(700);
|
|
}
|
|
// Scroll to editor, click Run
|
|
await page.evaluate(() => { const e = document.querySelector(".code-runner-editor .cm-editor"); if (e) e.scrollIntoView({ block: "start" }); });
|
|
await page.waitForTimeout(300);
|
|
await page.click("button:has-text(\"运行\")");
|
|
await page.waitForSelector(".xterm", { timeout: 10000 });
|
|
await page.waitForTimeout(2000);
|
|
console.log("[info] xterm mounted, code executed");
|
|
|
|
// Scroll xterm output into view (center of viewport)
|
|
await page.evaluate(() => {
|
|
const xterm = document.querySelector(".xterm-scrollable-element") || document.querySelector(".xterm");
|
|
if (xterm) xterm.scrollIntoView({ block: "center" });
|
|
});
|
|
await page.waitForTimeout(300);
|
|
|
|
// Find xterm output area + a control point equidistant from toggle origin.
|
|
// Toggle is at top of page (y≈40), but after scrolling it may be off-screen.
|
|
// VT animation uses toggle coords as circle origin regardless of scroll position
|
|
// (coords are viewport-relative from getBoundingClientRect). After scrollIntoView,
|
|
// toggle scrolls off top — its rect.top becomes negative. The VT circle origin
|
|
// will be at that negative y, meaning the circle starts above the viewport.
|
|
// For equidistant sampling: pick control point at same distance from toggle as xterm,
|
|
// placed horizontally adjacent (same scroll position, different x).
|
|
const geom = await page.evaluate(() => {
|
|
const xterm = document.querySelector(".xterm-scrollable-element") || document.querySelector(".xterm");
|
|
const toggle = document.querySelector(".theme-toggle");
|
|
if (!xterm || !toggle) return null;
|
|
const xr = xterm.getBoundingClientRect();
|
|
const tr = toggle.getBoundingClientRect();
|
|
// xterm sample: right edge, mid-height (pure bg, avoid text on left)
|
|
const xx = xr.right - 10;
|
|
const xy = xr.top + xr.height / 2;
|
|
const tx = tr.left + tr.width / 2;
|
|
const ty = tr.top + tr.height / 2;
|
|
const dist = Math.hypot(xx - tx, xy - ty);
|
|
// control: same distance from toggle, placed to the LEFT of xterm at same y
|
|
// (horizontal mirror preserves distance if toggle is centered; approximate)
|
|
// Better: place control at angle that keeps it in viewport. Use x = tx - (xx-tx),
|
|
// y = xy (mirror across toggle x). Distance = same.
|
|
const cx = Math.round(tx - (xx - tx));
|
|
const cy = Math.round(xy);
|
|
return {
|
|
xterm: { x: Math.round(xx), y: Math.round(xy) },
|
|
toggle: { x: Math.round(tx), y: Math.round(ty) },
|
|
control: { x: cx, y: cy },
|
|
dist: Math.round(dist),
|
|
};
|
|
});
|
|
if (!geom) { console.log("[fatal] no xterm found"); await browser.close(); return; }
|
|
console.log("[info] xterm sample:", geom.xterm, "control:", geom.control, "dist:", geom.dist);
|
|
|
|
async function sample(x, y) {
|
|
const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 }, omitBackground: false });
|
|
const px = decodePng(png);
|
|
return { ...px, hex: hex(px) };
|
|
}
|
|
|
|
const preX = await sample(geom.xterm.x, geom.xterm.y);
|
|
const preC = await sample(geom.control.x, geom.control.y);
|
|
console.log("[info] baseline xterm:", preX.hex, "control:", preC.hex);
|
|
|
|
// Trigger VT (Light → Dark) via __startThemeTransition
|
|
const frames = [];
|
|
const t0 = Date.now();
|
|
await page.evaluate((coords) => {
|
|
window.__startThemeTransition(coords.x, coords.y);
|
|
}, geom.toggle);
|
|
while (Date.now() - t0 < 1500) {
|
|
const t = Date.now() - t0;
|
|
const x = await sample(geom.xterm.x, geom.xterm.y);
|
|
const c = await sample(geom.control.x, geom.control.y);
|
|
frames.push({ t, xterm: x.hex, control: c.hex });
|
|
}
|
|
|
|
await page.waitForTimeout(300);
|
|
const postX = await sample(geom.xterm.x, geom.xterm.y);
|
|
const postC = await sample(geom.control.x, geom.control.y);
|
|
|
|
await browser.close();
|
|
|
|
console.log("\n========== 基线 ==========");
|
|
console.log(" xterm :", preX.hex, " control:", preC.hex);
|
|
console.log("\n========== 终态 ==========");
|
|
console.log(" xterm :", postX.hex, " control:", postC.hex);
|
|
|
|
console.log("\n========== 逐帧时间线 ==========");
|
|
console.log("t(ms)".padEnd(8) + "xterm".padEnd(12) + "control".padEnd(12));
|
|
let lastT = -100;
|
|
for (const f of frames) {
|
|
if (f.t - lastT < 40) continue;
|
|
lastT = f.t;
|
|
console.log(String(f.t).padEnd(8) + f.xterm.padEnd(12) + f.control.padEnd(12));
|
|
}
|
|
|
|
function firstChange(key, start) {
|
|
for (const f of frames) if (f[key] !== start) return f.t;
|
|
return -1;
|
|
}
|
|
const tX = firstChange("xterm", preX.hex);
|
|
const tC = firstChange("control", preC.hex);
|
|
console.log("\n========== 判定 ==========");
|
|
console.log(`xterm 首次跳变: t=${tX}ms (${preX.hex} -> ${postX.hex})`);
|
|
console.log(`control 首次跳变: t=${tC}ms (${preC.hex} -> ${postC.hex})`);
|
|
if (tX < 0 || tC < 0) {
|
|
console.log("? 未观察到完整跳变");
|
|
} else {
|
|
const lag = Math.abs(tX - tC);
|
|
console.log(lag <= 50
|
|
? `✓ xterm 与等距对照点同帧变色(lag=${lag}ms):终端区域参与 VT 动画。`
|
|
: `✗ ${tX > tC ? "xterm" : "control"} 滞后 ${lag}ms:终端区域与界面不同步。`);
|
|
}
|
|
}
|
|
|
|
main().catch((e) => { console.error("[fatal]", e); process.exit(1); });
|