fix: 修复Pio看板娘交互与贴图切换
This commit is contained in:
parent
0cdc68a79f
commit
e36fada219
@ -42,7 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium
|
||||
- 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。
|
||||
- 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。
|
||||
- Argon motion、滚动几何、延迟、RAF 调度、DOM id/selector、hash anchor 和跨组件 ref 必须从 `src/factories/blogAnimationFactory.ts` 与 `src/factories/blogDomFactory.ts` 取值;组件和 Hook 不再散写行为层 id、裸 `requestAnimationFrame`、裸 `setTimeout` 或重复 timing。
|
||||
- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 链路,静态运行时固定在 `public/live2d/wordpress-moc/live2d.min.js`;`BlogLive2D` 只在桌面端创建页面级单例 canvas,写入 `window.LAppDefine.MODELS = [['/api/blog/live2d/pio/moc/index.json']]` 并调用一次 `window.InitLive2D()`,hash 路由切换必须复用该实例。线上 MinIO Pio 公共根只允许 `catalog.json`、`moc/`、`moc3/` 三个入口,`moc/` 是当前 WordPress 同款源,`moc3/` 只保留重建模型资产;前端不得再接入自研 `KtPioLive2D`、manifest runtime、canvas transform fallback、pointer parallax fallback、`live2d-widgets` 或随机 CDN 模型。
|
||||
- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 链路,静态运行时固定在 `public/live2d/wordpress-moc/live2d.min.js`;`BlogLive2D` 只在桌面端创建页面级单例 canvas,写入 `window.LAppDefine.MODELS = [['/api/blog/live2d/pio/moc/index.json']]` 并调用一次 `window.InitLive2D()`,hash 路由切换必须复用该实例。鼠标视线追踪必须通过页面级 `mousemove` 写入旧 runtime 的 `dragMgr` 目标点,不限制在模型 canvas 内,也不能伪造 tap 事件;服装按钮必须先读取模型 `textures` 数量,单贴图时只提示不调用旧 runtime 的破坏式切换。线上 MinIO Pio 公共根只允许 `catalog.json`、`moc/`、`moc3/` 三个入口,`moc/` 是当前 WordPress 同款源,且 `index.json`、`manifest.json`、`textures/manifest.json` 的贴图计数必须一致;`moc3/` 只保留重建模型资产;前端不得再接入自研 `KtPioLive2D`、manifest runtime、canvas transform fallback、`live2d-widgets` 或随机 CDN 模型。
|
||||
- Modal 不强求一比一复刻线上 Argon 动画,打开/关闭 motion 与 `centered` 居中定位交给 antdv-next;外层按 `packages/@core/ui-kit/popup-ui/src/modal/modal.vue` 保留 Header/Content/Footer 三段能力、`p-0`、`max-height`、纵向 flex、Content `min-h-40` 滚动和 `px-5 py-4`/`p-3`/`p-2` 间距,但无 footer slot 时不渲染 footer,不搬 draggable/fullscreen/loading/footer 按钮等复杂能力,自有颜色只守 Blog 主题色和暗色可读性。
|
||||
- Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/<slug>?adminPreview=1&articleId=<id>`。
|
||||
|
||||
|
||||
@ -73,6 +73,26 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
|
||||
await expect(page.locator('.waifu-tool')).toHaveCSS('opacity', '0');
|
||||
await widget.hover();
|
||||
await expect(page.locator('.waifu-tool')).toHaveCSS('opacity', '1');
|
||||
await page.evaluate(() => {
|
||||
const state = window as unknown as {
|
||||
__ktDragPoints: Array<[number, number]>;
|
||||
dragMgr?: { setPoint: (x: number, y: number) => void };
|
||||
};
|
||||
state.__ktDragPoints = [];
|
||||
const originalSetPoint = state.dragMgr?.setPoint?.bind(state.dragMgr);
|
||||
if (state.dragMgr && originalSetPoint) {
|
||||
state.dragMgr.setPoint = (x: number, y: number) => {
|
||||
state.__ktDragPoints.push([x, y]);
|
||||
originalSetPoint(x, y);
|
||||
};
|
||||
}
|
||||
});
|
||||
await page.mouse.move(1180, 120);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __ktDragPoints?: Array<[number, number]> }).__ktDragPoints?.length ?? 0),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const state = window as unknown as { __ktTextureClicks: number };
|
||||
|
||||
@ -15,6 +15,9 @@ describe('mountWordPressLive2DRuntime', () => {
|
||||
delete window.InitLive2D;
|
||||
delete window.LAppDefine;
|
||||
delete window.LAppLive2DManager;
|
||||
delete window.dragMgr;
|
||||
delete window.transformViewX;
|
||||
delete window.transformViewY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@ -121,6 +124,46 @@ describe('mountWordPressLive2DRuntime', () => {
|
||||
expect(tapEvent?.call(manager, 0.9, 0.9)).toBe(false);
|
||||
expect(originalTapEvent).toHaveBeenCalledWith(0.9, 0.9);
|
||||
});
|
||||
|
||||
it('tracks page-level pointer movement instead of limiting look-at to the widget canvas', async () => {
|
||||
const setPoint = vi.fn();
|
||||
const originalTapEvent = vi.fn();
|
||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||
const element = originalAppendChild(node);
|
||||
if (element instanceof HTMLScriptElement) {
|
||||
window.InitLive2D = vi.fn(() => {
|
||||
window.dragMgr = { setPoint };
|
||||
window.transformViewX = vi.fn((deviceX: number) => deviceX / 100);
|
||||
window.transformViewY = vi.fn((deviceY: number) => deviceY / 100);
|
||||
});
|
||||
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||
window.LAppLive2DManager.prototype.tapEvent = originalTapEvent;
|
||||
element.onload?.(new Event('load'));
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
await mountWordPressLive2DRuntime();
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('canvas#live2d');
|
||||
expect(canvas).not.toBeNull();
|
||||
vi.spyOn(canvas!, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 768,
|
||||
height: 250,
|
||||
left: 0,
|
||||
right: 280,
|
||||
top: 518,
|
||||
width: 280,
|
||||
x: 0,
|
||||
y: 518,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 960, clientY: 260 }));
|
||||
|
||||
expect(setPoint).toHaveBeenCalledWith(9.6, -2.58);
|
||||
expect(originalTapEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlogLive2D', () => {
|
||||
@ -146,7 +189,10 @@ describe('BlogLive2D', () => {
|
||||
|
||||
it('mounts the WordPress Cubism2 MOC runtime in a WordPress-style widget shell', async () => {
|
||||
vi.stubGlobal('innerWidth', 1280);
|
||||
const fetchMock = vi.fn();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({ textures: ['textures/default-costume.png', 'textures/witch-costume.png'] }),
|
||||
ok: true,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
|
||||
const initLive2D = vi.fn();
|
||||
@ -190,7 +236,7 @@ describe('BlogLive2D', () => {
|
||||
expect(window.LAppDefine?.canvasSize).toEqual({ width: 280, height: 250 });
|
||||
expect(window.LAppDefine?.IS_START_TEXURE_CHANGE).toBe(true);
|
||||
expect(window.LAppDefine?.TEXURE_BUTTON_ID).toBe('live2d-texture-button');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledWith(DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY, { credentials: 'same-origin' });
|
||||
expect(initLive2D).toHaveBeenCalledTimes(1);
|
||||
expect(canvas?.style.transform).toBe('');
|
||||
expect(requestFrame).not.toHaveBeenCalled();
|
||||
@ -198,6 +244,39 @@ describe('BlogLive2D', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not trigger the destructive legacy texture reload when only one texture is available', async () => {
|
||||
vi.stubGlobal('innerWidth', 1280);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({ textures: ['textures/default-costume.png'] }),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||
const element = originalAppendChild(node);
|
||||
if (element instanceof HTMLScriptElement) {
|
||||
window.InitLive2D = vi.fn();
|
||||
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
|
||||
element.onload?.(new Event('load'));
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
mount(BlogLive2D);
|
||||
await flushPromises();
|
||||
const hiddenTextureButton = document.getElementById('live2d-texture-button') as HTMLButtonElement;
|
||||
const hiddenClick = vi.fn();
|
||||
hiddenTextureButton.addEventListener('click', hiddenClick);
|
||||
|
||||
document.querySelector<HTMLElement>('.waifu-tool .fui-eye')?.click();
|
||||
|
||||
expect(hiddenClick).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('.waifu-tips')?.textContent).toContain('只有一套');
|
||||
});
|
||||
|
||||
it('reuses the page-level WordPress runtime after route component remounts', async () => {
|
||||
vi.stubGlobal('innerWidth', 1280);
|
||||
const initLive2D = vi.fn();
|
||||
|
||||
@ -19,6 +19,7 @@ let runtimeScriptPromise: Promise<void> | null = null;
|
||||
let runtimeMountPromise: Promise<WordPressLive2DRuntimeHandle> | null = null;
|
||||
let mountedRuntimeCanvas: HTMLCanvasElement | null = null;
|
||||
let mountedWidgetController: WordPressWidgetControllerHandle | null = null;
|
||||
let mountedPagePointerBridge: WordPressPagePointerBridgeHandle | null = null;
|
||||
|
||||
export interface WordPressLive2DSettings {
|
||||
AUDIO_ID: string;
|
||||
@ -72,12 +73,26 @@ export interface WordPressLive2DRuntimeHandle {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dragMgr?: WordPressRuntimeTargetPoint;
|
||||
InitLive2D?: () => void;
|
||||
LAppDefine?: WordPressLive2DSettings;
|
||||
LAppLive2DManager?: WordPressLive2DManagerConstructor;
|
||||
transformViewX?: (deviceX: number) => number;
|
||||
transformViewY?: (deviceY: number) => number;
|
||||
}
|
||||
}
|
||||
|
||||
interface WordPressRuntimeTargetPoint {
|
||||
setPoint: (x: number, y: number) => void;
|
||||
}
|
||||
|
||||
interface WordPressPagePointerBridgeHandle {
|
||||
/**
|
||||
* Removes document-level pointer tracking installed for the legacy WordPress runtime.
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface WordPressLive2DManagerConstructor {
|
||||
prototype: WordPressLive2DManagerPrototype;
|
||||
}
|
||||
@ -168,6 +183,8 @@ export async function mountWordPressLive2DRuntime(
|
||||
mountedRuntimeCanvas = null;
|
||||
mountedWidgetController?.destroy();
|
||||
mountedWidgetController = null;
|
||||
mountedPagePointerBridge?.destroy();
|
||||
mountedPagePointerBridge = null;
|
||||
}
|
||||
|
||||
if (mountedRuntimeCanvas?.isConnected) {
|
||||
@ -245,11 +262,99 @@ async function initializeWordPressRuntime(
|
||||
|
||||
window.InitLive2D();
|
||||
mountedRuntimeCanvas = shell.canvas;
|
||||
mountedPagePointerBridge?.destroy();
|
||||
mountedPagePointerBridge = mountWordPressPagePointerBridge(shell.canvas);
|
||||
const textureCount = await resolveWordPressModelTextureCount(settings);
|
||||
mountedWidgetController?.destroy();
|
||||
mountedWidgetController = mountWordPressWidgetController(shell);
|
||||
mountedWidgetController = mountWordPressWidgetController(shell, { textureCount });
|
||||
return createWordPressRuntimeHandle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the WordPress plugin's page-wide mouse tracking without emitting synthetic tap events.
|
||||
* @param canvas Legacy runtime canvas used as the coordinate origin for model-view transforms.
|
||||
* @returns Cleanup handle for replacing a disconnected widget shell.
|
||||
*/
|
||||
function mountWordPressPagePointerBridge(canvas: HTMLCanvasElement): WordPressPagePointerBridgeHandle {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!canvas.isConnected || typeof window.dragMgr?.setPoint !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const deviceX = event.clientX - rect.left;
|
||||
const deviceY = event.clientY - rect.top;
|
||||
window.dragMgr.setPoint(resolveWordPressViewX(canvas, deviceX), resolveWordPressViewY(canvas, deviceY));
|
||||
};
|
||||
|
||||
const handleMouseOut = (event: MouseEvent) => {
|
||||
if (!event.relatedTarget && typeof window.dragMgr?.setPoint === 'function') {
|
||||
window.dragMgr.setPoint(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
window.addEventListener('mouseout', handleMouseOut, { passive: true });
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseout', handleMouseOut);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a page-level pointer x offset with the legacy runtime transform when available.
|
||||
* @param canvas Runtime canvas used for fallback normalization before the script exposes helpers.
|
||||
* @param deviceX Pointer x offset relative to the canvas left edge.
|
||||
* @returns Model-view x coordinate consumed by `L2DTargetPoint`.
|
||||
*/
|
||||
function resolveWordPressViewX(canvas: HTMLCanvasElement, deviceX: number): number {
|
||||
if (typeof window.transformViewX === 'function') {
|
||||
return window.transformViewX(deviceX);
|
||||
}
|
||||
return (deviceX - canvas.width / 2) * (2 / canvas.width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a page-level pointer y offset with the legacy runtime transform when available.
|
||||
* @param canvas Runtime canvas used for fallback normalization before the script exposes helpers.
|
||||
* @param deviceY Pointer y offset relative to the canvas top edge.
|
||||
* @returns Model-view y coordinate consumed by `L2DTargetPoint`.
|
||||
*/
|
||||
function resolveWordPressViewY(canvas: HTMLCanvasElement, deviceY: number): number {
|
||||
if (typeof window.transformViewY === 'function') {
|
||||
return window.transformViewY(deviceY);
|
||||
}
|
||||
return (deviceY - canvas.height / 2) * (-2 / canvas.width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active WordPress MOC model JSON so the toolbar can avoid destructive single-texture reloads.
|
||||
* @param settings Runtime settings whose first model entry points at the active Pio `index.json`.
|
||||
* @returns Number of usable texture entries, or `undefined` when the model JSON cannot be inspected.
|
||||
*/
|
||||
async function resolveWordPressModelTextureCount(settings: WordPressLive2DSettings): Promise<number | undefined> {
|
||||
const modelEntry = settings.MODELS[0]?.[0];
|
||||
if (!modelEntry || typeof window.fetch !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.fetch(modelEntry, { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const modelJson = (await response.json()) as { textures?: unknown };
|
||||
if (!Array.isArray(modelJson.textures)) {
|
||||
return undefined;
|
||||
}
|
||||
return modelJson.textures.filter((texture) => typeof texture === 'string' && texture.trim().length > 0).length;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the WordPress plugin toolbar with the original `fui-*` class contract.
|
||||
* @returns Toolbar element whose spans carry action metadata for the local controller.
|
||||
|
||||
@ -23,13 +23,22 @@ export interface WordPressWidgetControllerHandle {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface WordPressWidgetControllerOptions {
|
||||
/**
|
||||
* Number of texture entries declared by the active WordPress model JSON; single-texture models must not reload.
|
||||
*/
|
||||
textureCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs WordPress live-2d plugin toolbar behavior on the DOM shell that hosts the legacy runtime.
|
||||
* @param elements Stable DOM nodes created by the runtime bridge for the Pio widget.
|
||||
* @param options Runtime metadata used to protect legacy actions whose behavior depends on model assets.
|
||||
* @returns Cleanup handle for route/test teardown when the shell is rebuilt.
|
||||
*/
|
||||
export function mountWordPressWidgetController(
|
||||
elements: WordPressWidgetControllerElements,
|
||||
options: WordPressWidgetControllerOptions = {},
|
||||
): WordPressWidgetControllerHandle {
|
||||
let hideTipsTimer = 0;
|
||||
let closeTimer = 0;
|
||||
@ -108,6 +117,10 @@ export function mountWordPressWidgetController(
|
||||
saveCanvas();
|
||||
break;
|
||||
case 'texture':
|
||||
if (typeof options.textureCount === 'number' && options.textureCount < 2) {
|
||||
showMessage(WORDPRESS_WAIFU_MESSAGES.singleTexture);
|
||||
break;
|
||||
}
|
||||
elements.textureButton.click();
|
||||
showMessage(WORDPRESS_WAIFU_MESSAGES.texture);
|
||||
break;
|
||||
|
||||
@ -6,6 +6,7 @@ export const WORDPRESS_WAIFU_MESSAGES = {
|
||||
copy: '复制成功,记得带上出处哦。',
|
||||
model: '当前只启用 Pio 模型。',
|
||||
photo: '已尝试保存当前画面。',
|
||||
singleTexture: '当前只有一套服装。',
|
||||
texture: '正在切换 Pio 的服装。',
|
||||
touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'],
|
||||
welcome: '欢迎来到 KwiTsukasa 的小站。',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user