diff --git a/README.md b/README.md index c490ad6..53e1b2f 100644 --- a/README.md +++ b/README.md @@ -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/?adminPreview=1&articleId=`。 diff --git a/e2e/live2d-wordpress-widget.spec.ts b/e2e/live2d-wordpress-widget.spec.ts index 2222829..05e363d 100644 --- a/e2e/live2d-wordpress-widget.spec.ts +++ b/e2e/live2d-wordpress-widget.spec.ts @@ -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 }; diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index eee1034..6a77014 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -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('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('.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(); diff --git a/src/components/blog/live2d/wordpressRuntimeBridge.ts b/src/components/blog/live2d/wordpressRuntimeBridge.ts index 5c58e3c..99197a5 100644 --- a/src/components/blog/live2d/wordpressRuntimeBridge.ts +++ b/src/components/blog/live2d/wordpressRuntimeBridge.ts @@ -19,6 +19,7 @@ let runtimeScriptPromise: Promise | null = null; let runtimeMountPromise: Promise | 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 { + 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. diff --git a/src/components/blog/live2d/wordpressWidgetController.ts b/src/components/blog/live2d/wordpressWidgetController.ts index 0a7712b..6329136 100644 --- a/src/components/blog/live2d/wordpressWidgetController.ts +++ b/src/components/blog/live2d/wordpressWidgetController.ts @@ -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; diff --git a/src/components/blog/live2d/wordpressWidgetMessages.ts b/src/components/blog/live2d/wordpressWidgetMessages.ts index 3860bac..78f854a 100644 --- a/src/components/blog/live2d/wordpressWidgetMessages.ts +++ b/src/components/blog/live2d/wordpressWidgetMessages.ts @@ -6,6 +6,7 @@ export const WORDPRESS_WAIFU_MESSAGES = { copy: '复制成功,记得带上出处哦。', model: '当前只启用 Pio 模型。', photo: '已尝试保存当前画面。', + singleTexture: '当前只有一套服装。', texture: '正在切换 Pio 的服装。', touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'], welcome: '欢迎来到 KwiTsukasa 的小站。',