diff --git a/e2e/live2d-wordpress-widget.spec.ts b/e2e/live2d-wordpress-widget.spec.ts new file mode 100644 index 0000000..2222829 --- /dev/null +++ b/e2e/live2d-wordpress-widget.spec.ts @@ -0,0 +1,123 @@ +/// + +import { expect, test, type Page, type Route } from '@playwright/test'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const LOCAL_MOC_ROOT = path.resolve(process.cwd(), '..', '..', '.kt-workspace', 'live2d-pio', 'runtime-publish', 'moc'); +const CONTENT_TYPES = new Map([ + ['.json', 'application/json'], + ['.moc', 'application/octet-stream'], + ['.mtn', 'application/json'], + ['.png', 'image/png'], +]); + +test.skip(!existsSync(LOCAL_MOC_ROOT), 'local Pio MOC runtime package is required for widget parity e2e'); + +/** + * Resolves a Blog Live2D API request into the local captured WordPress MOC package. + * @param route Browser route whose request targets `/api/blog/live2d/pio/moc/**`. + */ +async function fulfillLocalMocAsset(route: Route) { + const url = new URL(route.request().url()); + const relativeAssetPath = decodeURIComponent(url.pathname.replace('/api/blog/live2d/pio/moc/', '')); + const filePath = path.resolve(LOCAL_MOC_ROOT, relativeAssetPath); + if (!filePath.startsWith(LOCAL_MOC_ROOT) || !existsSync(filePath) || !statSync(filePath).isFile()) { + await route.fulfill({ body: 'not found', status: 404 }); + return; + } + + await route.fulfill({ + body: readFileSync(filePath), + contentType: CONTENT_TYPES.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream', + status: 200, + }); +} + +/** + * Routes the Pio MOC API to local release artifacts and records runtime console errors. + * @param page Playwright page that will open Blog Web. + * @param errors Mutable error list used by assertions after interactions complete. + */ +async function prepareLive2DPage(page: Page, errors: string[]) { + await page.route('**/api/blog/live2d/pio/moc/**', fulfillLocalMocAsset); + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()); + } + }); +} + +/** + * Verifies the restored WordPress Pio widget shell and toolbar interactions on desktop. + * @param page Playwright page for the local Blog Web app. + */ +async function verifyDesktopWidgetParity({ page }: { page: Page }) { + const errors: string[] = []; + await prepareLive2DPage(page, errors); + await page.setViewportSize({ height: 768, width: 1366 }); + await page.goto('/'); + + const widget = page.locator('.waifu.kt-blog__live2d-widget'); + const canvas = page.locator('canvas#live2d.live2d.kt-blog__live2d-canvas'); + await expect(widget).toBeVisible(); + await expect(canvas).toHaveAttribute('width', '280'); + await expect(canvas).toHaveAttribute('height', '250'); + const widgetBox = await widget.boundingBox(); + expect(widgetBox).not.toBeNull(); + expect(Math.round(widgetBox?.x ?? -1)).toBe(0); + expect(Math.round(768 - ((widgetBox?.y ?? 0) + (widgetBox?.height ?? 0)))).toBe(0); + + await expect(page.locator('.waifu-tool > span')).toHaveCount(8); + 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 { __ktTextureClicks: number }; + state.__ktTextureClicks = 0; + document.getElementById('live2d-texture-button')?.addEventListener('click', () => { + state.__ktTextureClicks += 1; + }); + }); + await page.locator('.waifu-tool .fui-chat').click(); + await expect(page.locator('.gptInput')).toHaveClass(/show/); + await expect(page.locator('#live2dChatText')).toBeFocused(); + await page.locator('.waifu-tool .fui-eye').click(); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __ktTextureClicks: number }).__ktTextureClicks), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate(() => document.querySelector('canvas#live2d')?.toDataURL('image/png').length ?? 0), + ) + .toBeGreaterThan(1000); + await page.locator('.waifu-tool .fui-photo').click(); + await expect(page.locator('.waifu-tips')).toContainText('保存'); + await page.locator('.waifu-tool .fui-info-circle').click(); + await expect(page.locator('.waifu-tips')).toContainText('Pio'); + await page.locator('.waifu-tool .fui-cross').click(); + await expect(widget).toHaveCSS('display', 'none', { timeout: 2000 }); + expect(errors).toEqual([]); +} + +/** + * Verifies mobile viewports do not mount the WordPress runtime below the old plugin threshold. + * @param page Playwright page for the local Blog Web app. + */ +async function verifyMobileSkipsWidget({ page }: { page: Page }) { + const errors: string[] = []; + await prepareLive2DPage(page, errors); + await page.setViewportSize({ height: 720, width: 768 }); + await page.goto('/'); + + await expect(page.locator('.waifu.kt-blog__live2d-widget')).toHaveCount(0); + await expect(page.locator('canvas#live2d')).toHaveCount(0); + expect(errors).toEqual([]); +} + +test('WordPress Pio widget shell and toolbar interactions match the captured plugin', verifyDesktopWidgetParity); +test('WordPress Pio widget stays disabled at the captured mobile threshold', verifyMobileSkipsWidget); diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index efcec9d..eee1034 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -38,7 +38,8 @@ describe('mountWordPressLive2DRuntime', () => { const runtimeScript = document.querySelector('script#kt-blog-pio-wordpress-live2d-runtime'); expect(runtimeScript?.getAttribute('src')).toBe(DEFAULT_WORDPRESS_LIVE2D_SCRIPT); expect(document.querySelectorAll('script#kt-blog-pio-wordpress-live2d-runtime')).toHaveLength(1); - expect(document.querySelectorAll('canvas#live2d.kt-blog__live2d-canvas')).toHaveLength(1); + expect(document.querySelectorAll('.waifu.kt-blog__live2d-widget')).toHaveLength(1); + expect(document.querySelectorAll('canvas#live2d.live2d.kt-blog__live2d-canvas')).toHaveLength(1); expect(initLive2D).toHaveBeenCalledTimes(1); expect(window.LAppDefine).toMatchObject(DEFAULT_WORDPRESS_LIVE2D_SETTINGS); expect(window.LAppDefine?.MODELS).toEqual([[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]]); @@ -61,7 +62,8 @@ describe('mountWordPressLive2DRuntime', () => { await Promise.resolve(); expect(document.querySelectorAll('script#kt-blog-pio-wordpress-live2d-runtime')).toHaveLength(1); - expect(document.querySelectorAll('canvas#live2d.kt-blog__live2d-canvas')).toHaveLength(1); + expect(document.querySelectorAll('.waifu.kt-blog__live2d-widget')).toHaveLength(1); + expect(document.querySelectorAll('canvas#live2d.live2d.kt-blog__live2d-canvas')).toHaveLength(1); window.InitLive2D = initLive2D; window.LAppLive2DManager = function LAppLive2DManager() {}; @@ -131,8 +133,8 @@ describe('BlogLive2D', () => { vi.restoreAllMocks(); }); - it('does not load the WordPress runtime below the desktop viewport', async () => { - vi.stubGlobal('innerWidth', 1199); + it('does not load the WordPress runtime below the WordPress waifu min width', async () => { + vi.stubGlobal('innerWidth', 768); const appendChild = vi.spyOn(document.body, 'appendChild'); mount(BlogLive2D); @@ -142,7 +144,7 @@ describe('BlogLive2D', () => { expect(document.querySelector('canvas#live2d')).toBeNull(); }); - it('mounts the WordPress Cubism2 MOC runtime on desktop without manifest fetch or canvas fallback motion', async () => { + it('mounts the WordPress Cubism2 MOC runtime in a WordPress-style widget shell', async () => { vi.stubGlobal('innerWidth', 1280); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); @@ -163,11 +165,31 @@ describe('BlogLive2D', () => { const wrapper = mount(BlogLive2D); await flushPromises(); - const canvas = document.querySelector('canvas#live2d.kt-blog__live2d-canvas') as HTMLCanvasElement | null; + const widget = document.querySelector('.waifu.kt-blog__live2d-widget'); + const canvas = document.querySelector('canvas#live2d.live2d.kt-blog__live2d-canvas') as HTMLCanvasElement | null; + const tools = Array.from(document.querySelectorAll('.waifu-tool > span')).map((node) => node.className); expect(wrapper.find('canvas#live2d').exists()).toBe(false); + expect(widget).not.toBeNull(); expect(canvas).not.toBeNull(); expect(canvas?.width).toBe(DEFAULT_WORDPRESS_LIVE2D_SETTINGS.canvasSize.width); expect(canvas?.height).toBe(DEFAULT_WORDPRESS_LIVE2D_SETTINGS.canvasSize.height); + expect(tools).toEqual([ + 'fui-home', + 'fui-chat', + 'fui-bot', + 'fui-eye', + 'fui-user', + 'fui-photo', + 'fui-info-circle', + 'fui-cross', + ]); + expect(document.querySelector('.gptInput #live2dChatText')).not.toBeNull(); + expect(document.querySelector('.gptInput #live2dSend')).not.toBeNull(); + expect(document.querySelector('.gptInput #live2dSendClose')).not.toBeNull(); + expect(document.getElementById('live2d-texture-button')).not.toBeNull(); + 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(initLive2D).toHaveBeenCalledTimes(1); expect(canvas?.style.transform).toBe(''); @@ -197,7 +219,8 @@ describe('BlogLive2D', () => { const secondWrapper = mount(BlogLive2D); await flushPromises(); - expect(document.querySelectorAll('canvas#live2d.kt-blog__live2d-canvas')).toHaveLength(1); + expect(document.querySelectorAll('.waifu.kt-blog__live2d-widget')).toHaveLength(1); + expect(document.querySelectorAll('canvas#live2d.live2d.kt-blog__live2d-canvas')).toHaveLength(1); expect(document.querySelectorAll('script#kt-blog-pio-wordpress-live2d-runtime')).toHaveLength(1); expect(initLive2D).toHaveBeenCalledTimes(1); diff --git a/src/components/blog/BlogLive2D.tsx b/src/components/blog/BlogLive2D.tsx index bf34ee8..3e54f10 100644 --- a/src/components/blog/BlogLive2D.tsx +++ b/src/components/blog/BlogLive2D.tsx @@ -1,7 +1,6 @@ import { defineComponent, onBeforeUnmount, onMounted } from 'vue'; -import { BLOG_VIEWPORT_GEOMETRY } from '@/factories/blogAnimationFactory'; - +import { WORDPRESS_WAIFU_MIN_WIDTH } from './live2d/wordpressWidgetConfig'; import { mountWordPressLive2DRuntime, type WordPressLive2DRuntimeHandle, @@ -14,7 +13,7 @@ export default defineComponent({ * @returns Render function that only creates the canvas on desktop viewports. */ setup() { - const isDesktop = window.innerWidth >= BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx; + const isDesktop = window.innerWidth > WORDPRESS_WAIFU_MIN_WIDTH; let disposed = false; let runtimeHandle: WordPressLive2DRuntimeHandle | null = null; diff --git a/src/components/blog/live2d/wordpressRuntimeBridge.ts b/src/components/blog/live2d/wordpressRuntimeBridge.ts index 5b0f423..5c58e3c 100644 --- a/src/components/blog/live2d/wordpressRuntimeBridge.ts +++ b/src/components/blog/live2d/wordpressRuntimeBridge.ts @@ -1,11 +1,24 @@ import { blogDomId } from '@/factories/blogDomFactory'; +import { + WORDPRESS_WAIFU_SIZE, + WORDPRESS_WAIFU_TEXTURE_BUTTON_ID, + WORDPRESS_WAIFU_TOOLS, +} from './wordpressWidgetConfig'; +import { + mountWordPressWidgetController, + type WordPressWidgetControllerElements, + type WordPressWidgetControllerHandle, +} from './wordpressWidgetController'; + const WORDPRESS_RUNTIME_SCRIPT_ID = 'kt-blog-pio-wordpress-live2d-runtime'; const WORDPRESS_RUNTIME_CANVAS_CLASS = 'kt-blog__live2d-canvas'; +const WORDPRESS_RUNTIME_WIDGET_CLASS = 'kt-blog__live2d-widget'; const WORDPRESS_RUNTIME_HIT_AREA_BRIDGE_MARKER = '__ktBlogPioCustomHitAreaBridge'; let runtimeScriptPromise: Promise | null = null; let runtimeMountPromise: Promise | null = null; let mountedRuntimeCanvas: HTMLCanvasElement | null = null; +let mountedWidgetController: WordPressWidgetControllerHandle | null = null; export interface WordPressLive2DSettings { AUDIO_ID: string; @@ -94,6 +107,8 @@ interface WordPressLive2DCustomHitAreas { head_y?: [number, number]; } +type WordPressRuntimeWidgetShell = WordPressWidgetControllerElements; + export const DEFAULT_WORDPRESS_LIVE2D_SCRIPT = '/live2d/wordpress-moc/live2d.min.js'; export const DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY = '/api/blog/live2d/pio/moc/index.json'; @@ -111,7 +126,7 @@ export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = { IS_BIND_BUTTON: false, IS_PLAY_AUDIO: false, IS_SCROLL_SCALE: false, - IS_START_TEXURE_CHANGE: false, + IS_START_TEXURE_CHANGE: true, MODELS: [[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]], MOTION_GROUP_FLICK_HEAD: 'flick_head', MOTION_GROUP_IDLE: 'idle', @@ -125,7 +140,7 @@ export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = { PRIORITY_NONE: 0, PRIORITY_NORMAL: 2, SCALE: 1, - TEXURE_BUTTON_ID: '', + TEXURE_BUTTON_ID: WORDPRESS_WAIFU_TEXTURE_BUTTON_ID, TEXURE_CHANGE_MODE: 'sequence', VIEW_LOGICAL_LEFT: -1, VIEW_LOGICAL_MAX_BOTTOM: -2, @@ -136,8 +151,8 @@ export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = { VIEW_MAX_SCALE: 2, VIEW_MIN_SCALE: 0.8, canvasSize: { - height: 320, - width: 220, + height: WORDPRESS_WAIFU_SIZE.height, + width: WORDPRESS_WAIFU_SIZE.width, }, }; @@ -149,8 +164,13 @@ export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = { export async function mountWordPressLive2DRuntime( settings: WordPressLive2DSettings = DEFAULT_WORDPRESS_LIVE2D_SETTINGS, ): Promise { - const canvas = ensureWordPressRuntimeCanvas(settings); - if (mountedRuntimeCanvas?.isConnected && mountedRuntimeCanvas === canvas) { + if (mountedRuntimeCanvas && !mountedRuntimeCanvas.isConnected) { + mountedRuntimeCanvas = null; + mountedWidgetController?.destroy(); + mountedWidgetController = null; + } + + if (mountedRuntimeCanvas?.isConnected) { return createWordPressRuntimeHandle(); } @@ -158,47 +178,65 @@ export async function mountWordPressLive2DRuntime( return runtimeMountPromise; } - runtimeMountPromise = initializeWordPressRuntime(canvas, settings).finally(() => { + const shell = ensureWordPressRuntimeWidgetShell(settings); + runtimeMountPromise = initializeWordPressRuntime(shell, settings).finally(() => { runtimeMountPromise = null; }); return runtimeMountPromise; } /** - * Creates or reuses the page-level Pio canvas so hash route switches do not destroy the WebGL owner. + * Creates or reuses the page-level WordPress waifu DOM shell so hash route switches do not destroy WebGL state. * @param settings WordPress Cubism2 runtime settings that define canvas id and intrinsic WebGL size. - * @returns Connected canvas element passed to the WordPress runtime. + * @returns Connected widget shell elements used by the runtime and local toolbar controller. */ -function ensureWordPressRuntimeCanvas(settings: WordPressLive2DSettings): HTMLCanvasElement { +function ensureWordPressRuntimeWidgetShell(settings: WordPressLive2DSettings): WordPressRuntimeWidgetShell { const canvasId = settings.CANVAS_ID; - const existingCanvas = document.getElementById(canvasId); - if (existingCanvas instanceof HTMLCanvasElement && existingCanvas.isConnected) { - existingCanvas.classList.add(WORDPRESS_RUNTIME_CANVAS_CLASS); - existingCanvas.width = settings.canvasSize.width; - existingCanvas.height = settings.canvasSize.height; - return existingCanvas; - } + const existingWidget = document.querySelector(`.waifu.${WORDPRESS_RUNTIME_WIDGET_CLASS}`); + const widget = existingWidget ?? document.createElement('div'); + widget.className = `waifu ${WORDPRESS_RUNTIME_WIDGET_CLASS}`; + widget.style.display = ''; const canvas = document.createElement('canvas'); canvas.id = canvasId; - canvas.className = WORDPRESS_RUNTIME_CANVAS_CLASS; + canvas.className = `live2d ${WORDPRESS_RUNTIME_CANVAS_CLASS}`; canvas.width = settings.canvasSize.width; canvas.height = settings.canvasSize.height; - document.body.appendChild(canvas); - return canvas; + const tips = document.createElement('div'); + tips.className = 'waifu-tips'; + const tool = createWordPressToolMenu(); + const chat = createWordPressChatPanel(); + const textureButton = createWordPressHiddenButton(settings.TEXURE_BUTTON_ID); + + widget.replaceChildren(tips, canvas, tool, chat.container, textureButton); + if (!existingWidget) { + document.body.appendChild(widget); + } + + return { + canvas, + chat: chat.container, + closeButton: chat.closeButton, + input: chat.input, + sendButton: chat.sendButton, + textureButton, + tips, + tool, + widget, + }; } /** * Loads the WordPress runtime script and starts it against the singleton canvas. - * @param canvas Persistent canvas whose id is passed through `window.LAppDefine.CANVAS_ID`. + * @param shell Persistent WordPress widget shell whose canvas id is passed through `window.LAppDefine.CANVAS_ID`. * @param settings WordPress runtime settings for model entry, sizing, and interaction motions. * @returns Runtime handle that preserves the page-level singleton on Vue route teardown. */ async function initializeWordPressRuntime( - canvas: HTMLCanvasElement, + shell: WordPressRuntimeWidgetShell, settings: WordPressLive2DSettings, ): Promise { - window.LAppDefine = createWordPressRuntimeSettings(settings, canvas.id); + window.LAppDefine = createWordPressRuntimeSettings(settings, shell.canvas.id); await appendWordPressRuntimeScript(DEFAULT_WORDPRESS_LIVE2D_SCRIPT); installWordPressCustomHitAreaBridge(settings); if (!window.InitLive2D) { @@ -206,10 +244,83 @@ async function initializeWordPressRuntime( } window.InitLive2D(); - mountedRuntimeCanvas = canvas; + mountedRuntimeCanvas = shell.canvas; + mountedWidgetController?.destroy(); + mountedWidgetController = mountWordPressWidgetController(shell); return createWordPressRuntimeHandle(); } +/** + * Creates the WordPress plugin toolbar with the original `fui-*` class contract. + * @returns Toolbar element whose spans carry action metadata for the local controller. + */ +function createWordPressToolMenu(): HTMLElement { + const tool = document.createElement('div'); + tool.className = 'waifu-tool'; + for (const item of WORDPRESS_WAIFU_TOOLS) { + const button = document.createElement('span'); + button.className = item.className; + button.dataset.live2dAction = item.action; + button.title = item.title; + button.setAttribute('role', 'button'); + button.setAttribute('tabindex', '0'); + tool.appendChild(button); + } + return tool; +} + +interface WordPressChatPanel { + closeButton: HTMLButtonElement; + container: HTMLElement; + input: HTMLInputElement; + sendButton: HTMLButtonElement; +} + +/** + * Creates the WordPress live-2d GPT input panel while leaving future backend wiring explicit. + * @returns Input panel nodes needed by toolbar actions and local close/send behavior. + */ +function createWordPressChatPanel(): WordPressChatPanel { + const container = document.createElement('div'); + container.className = 'gptInput'; + + const input = document.createElement('input'); + input.id = blogDomId('live2dChatText'); + input.placeholder = '和 Pio 说点什么...'; + input.type = 'text'; + + const sendButton = document.createElement('button'); + sendButton.id = blogDomId('live2dSend'); + sendButton.type = 'button'; + sendButton.textContent = '发送'; + + const closeButton = document.createElement('button'); + closeButton.id = blogDomId('live2dSendClose'); + closeButton.type = 'button'; + closeButton.textContent = '关闭'; + + container.append(input, sendButton, closeButton); + return { + closeButton, + container, + input, + sendButton, + }; +} + +/** + * Creates the hidden texture button consumed by the legacy runtime's `changeTexure` binding. + * @param id Stable DOM id passed to `window.LAppDefine.TEXURE_BUTTON_ID`. + * @returns Hidden button clicked by the local toolbar texture action. + */ +function createWordPressHiddenButton(id: string): HTMLButtonElement { + const button = document.createElement('button'); + button.id = id; + button.hidden = true; + button.type = 'button'; + return button; +} + /** * Builds the global `LAppDefine` object consumed by the legacy WordPress Cubism2 runtime. * @param settings Vue-owned runtime settings and model entry URL. diff --git a/src/components/blog/live2d/wordpressWidgetConfig.ts b/src/components/blog/live2d/wordpressWidgetConfig.ts new file mode 100644 index 0000000..c194dc5 --- /dev/null +++ b/src/components/blog/live2d/wordpressWidgetConfig.ts @@ -0,0 +1,34 @@ +import { blogDomId } from '@/factories/blogDomFactory'; + +export const WORDPRESS_WAIFU_SIZE = { + height: 250, + width: 280, +} as const; + +export const WORDPRESS_WAIFU_TIPS_SIZE = { + height: 70, + width: 250, +} as const; + +export const WORDPRESS_WAIFU_MIN_WIDTH = 768; + +export const WORDPRESS_WAIFU_TEXTURE_BUTTON_ID = blogDomId('live2dTextureButton'); + +export type WordPressWaifuToolAction = 'bot' | 'chat' | 'close' | 'home' | 'info' | 'model' | 'photo' | 'texture'; + +export interface WordPressWaifuToolDefinition { + action: WordPressWaifuToolAction; + className: string; + title: string; +} + +export const WORDPRESS_WAIFU_TOOLS: readonly WordPressWaifuToolDefinition[] = [ + { action: 'home', className: 'fui-home', title: '回到首页' }, + { action: 'chat', className: 'fui-chat', title: '打开聊天' }, + { action: 'bot', className: 'fui-bot', title: '打开 AI 输入' }, + { action: 'texture', className: 'fui-eye', title: '切换服装' }, + { action: 'model', className: 'fui-user', title: '切换模型' }, + { action: 'photo', className: 'fui-photo', title: '保存截图' }, + { action: 'info', className: 'fui-info-circle', title: '关于 Pio' }, + { action: 'close', className: 'fui-cross', title: '隐藏 Pio' }, +] as const; diff --git a/src/components/blog/live2d/wordpressWidgetController.ts b/src/components/blog/live2d/wordpressWidgetController.ts new file mode 100644 index 0000000..0a7712b --- /dev/null +++ b/src/components/blog/live2d/wordpressWidgetController.ts @@ -0,0 +1,186 @@ +import type { WordPressWaifuToolAction } from './wordpressWidgetConfig'; +import { WORDPRESS_WAIFU_MESSAGES } from './wordpressWidgetMessages'; + +const WORDPRESS_WAIFU_TIP_VISIBLE_MS = 5000; +const WORDPRESS_WAIFU_CLOSE_DELAY_MS = 1300; + +export interface WordPressWidgetControllerElements { + canvas: HTMLCanvasElement; + chat: HTMLElement; + closeButton: HTMLButtonElement; + input: HTMLInputElement; + sendButton: HTMLButtonElement; + textureButton: HTMLButtonElement; + tips: HTMLElement; + tool: HTMLElement; + widget: HTMLElement; +} + +export interface WordPressWidgetControllerHandle { + /** + * Removes event listeners owned by the Vue bridge without touching the legacy Live2D runtime. + */ + destroy(): void; +} + +/** + * 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. + * @returns Cleanup handle for route/test teardown when the shell is rebuilt. + */ +export function mountWordPressWidgetController( + elements: WordPressWidgetControllerElements, +): WordPressWidgetControllerHandle { + let hideTipsTimer = 0; + let closeTimer = 0; + + /** + * Shows the WordPress-style tips bubble and schedules it to fade like the original plugin. + * @param message Trusted local message rendered in the Pio tips bubble. + */ + const showMessage = (message: string) => { + window.clearTimeout(hideTipsTimer); + elements.tips.textContent = message; + elements.tips.classList.add('show'); + hideTipsTimer = window.setTimeout(() => { + elements.tips.classList.remove('show'); + }, WORDPRESS_WAIFU_TIP_VISIBLE_MS); + }; + + /** + * Opens or closes the WordPress chat input panel and mirrors its state through tips. + */ + const toggleChat = () => { + const willShow = !elements.chat.classList.contains('show'); + elements.chat.classList.toggle('show', willShow); + if (willShow) { + elements.input.focus(); + showMessage(WORDPRESS_WAIFU_MESSAGES.chatOpen); + return; + } + showMessage(WORDPRESS_WAIFU_MESSAGES.chatClose); + }; + + /** + * Saves the currently rendered Live2D canvas as a PNG when the browser allows canvas export. + */ + const saveCanvas = () => { + try { + const link = document.createElement('a'); + link.href = elements.canvas.toDataURL('image/png'); + link.download = 'pio-live2d.png'; + document.body.appendChild(link); + link.click(); + link.remove(); + showMessage(WORDPRESS_WAIFU_MESSAGES.photo); + } catch { + showMessage('当前画面无法保存。'); + } + }; + + /** + * Dispatches one toolbar action using the same feature set exposed by the WordPress plugin shell. + * @param action WordPress toolbar action encoded on the clicked `fui-*` icon span. + */ + const dispatchToolAction = (action: WordPressWaifuToolAction) => { + switch (action) { + case 'home': + window.location.href = '/'; + break; + case 'bot': + case 'chat': + toggleChat(); + break; + case 'close': + showMessage(WORDPRESS_WAIFU_MESSAGES.close); + window.clearTimeout(closeTimer); + closeTimer = window.setTimeout(() => { + elements.widget.style.display = 'none'; + }, WORDPRESS_WAIFU_CLOSE_DELAY_MS); + break; + case 'info': + showMessage(WORDPRESS_WAIFU_MESSAGES.about); + break; + case 'model': + showMessage(WORDPRESS_WAIFU_MESSAGES.model); + break; + case 'photo': + saveCanvas(); + break; + case 'texture': + elements.textureButton.click(); + showMessage(WORDPRESS_WAIFU_MESSAGES.texture); + break; + default: + break; + } + }; + + /** + * Handles clicks bubbling from the old `waifu-tool` span list. + * @param event Browser click event emitted from the widget toolbar. + */ + const handleToolClick = (event: Event) => { + const target = event.target instanceof Element ? event.target.closest('[data-live2d-action]') : null; + const action = target?.dataset.live2dAction as WordPressWaifuToolAction | undefined; + if (!action) { + return; + } + event.preventDefault(); + dispatchToolAction(action); + }; + + /** + * Displays one of the local touch messages when the canvas receives a pointer click. + */ + const handleCanvasClick = () => { + const messages = WORDPRESS_WAIFU_MESSAGES.touch; + const index = Math.floor(Math.random() * messages.length); + showMessage(messages[index] ?? WORDPRESS_WAIFU_MESSAGES.welcome); + }; + + /** + * Mirrors the WordPress plugin's copy feedback bubble after page content is copied. + */ + const handleCopy = () => { + showMessage(WORDPRESS_WAIFU_MESSAGES.copy); + }; + + /** + * Closes the GPT input without removing the widget. + * @param event Button click event from the WordPress input close control. + */ + const handleChatClose = (event: Event) => { + event.preventDefault(); + elements.chat.classList.remove('show'); + showMessage(WORDPRESS_WAIFU_MESSAGES.chatClose); + }; + + /** + * Emits a local acknowledgement for the preserved WordPress GPT input surface. + * @param event Submit click event from the WordPress input send control. + */ + const handleChatSend = (event: Event) => { + event.preventDefault(); + showMessage(elements.input.value.trim() ? '我听到了。' : '先写点内容吧。'); + }; + + elements.tool.addEventListener('click', handleToolClick); + elements.canvas.addEventListener('click', handleCanvasClick); + document.addEventListener('copy', handleCopy); + elements.closeButton.addEventListener('click', handleChatClose); + elements.sendButton.addEventListener('click', handleChatSend); + showMessage(WORDPRESS_WAIFU_MESSAGES.welcome); + + return { + destroy() { + window.clearTimeout(hideTipsTimer); + window.clearTimeout(closeTimer); + elements.tool.removeEventListener('click', handleToolClick); + elements.canvas.removeEventListener('click', handleCanvasClick); + document.removeEventListener('copy', handleCopy); + elements.closeButton.removeEventListener('click', handleChatClose); + elements.sendButton.removeEventListener('click', handleChatSend); + }, + }; +} diff --git a/src/components/blog/live2d/wordpressWidgetMessages.ts b/src/components/blog/live2d/wordpressWidgetMessages.ts new file mode 100644 index 0000000..3860bac --- /dev/null +++ b/src/components/blog/live2d/wordpressWidgetMessages.ts @@ -0,0 +1,12 @@ +export const WORDPRESS_WAIFU_MESSAGES = { + about: '这是从旧 WordPress Live2D 插件迁移过来的 Pio 看板娘。', + chatClose: '输入框已经收起来了。', + chatOpen: '想说点什么吗?', + close: '下次再见。', + copy: '复制成功,记得带上出处哦。', + model: '当前只启用 Pio 模型。', + photo: '已尝试保存当前画面。', + texture: '正在切换 Pio 的服装。', + touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'], + welcome: '欢迎来到 KwiTsukasa 的小站。', +} as const; diff --git a/src/factories/blogDomFactory.ts b/src/factories/blogDomFactory.ts index db760d2..6d1487b 100644 --- a/src/factories/blogDomFactory.ts +++ b/src/factories/blogDomFactory.ts @@ -29,6 +29,10 @@ export const BLOG_DOM_IDS = { leftbarSearchContainer: 'leftbar_search_container', leftbarSearchInput: 'leftbar_search_input', live2dCanvas: 'live2d', + live2dChatText: 'live2dChatText', + live2dSend: 'live2dSend', + live2dSendClose: 'live2dSendClose', + live2dTextureButton: 'live2d-texture-button', main: 'main', openSidebar: 'open_sidebar', postArticleTitle: 'post_article_title', diff --git a/src/styles/base.scss b/src/styles/base.scss index 279cad1..24f0d07 100644 --- a/src/styles/base.scss +++ b/src/styles/base.scss @@ -274,13 +274,175 @@ html.filter-darkness .kt-blog__primary::after { margin-bottom: 1rem; } - &__live2d-canvas { + &__live2d-widget { position: fixed; - right: 16px; - bottom: 76px; - z-index: 30; - width: 220px; - height: 320px; + bottom: 0; + left: 0; + z-index: 20; + width: 280px; + font-size: 0; + pointer-events: none; + + &:hover .waifu-tool { + opacity: 1; + transform: translateX(0); + } + + .waifu-tips { + position: absolute; + bottom: 246px; + left: 20px; + z-index: 21; + width: 250px; + min-height: 70px; + padding: 5px 10px; + border: 1px solid rgba(224, 186, 140, 0.62); + border-radius: 12px; + opacity: 0; + color: #32373c; + background: rgba(236, 217, 188, 0.5); + box-shadow: 0 3px 15px 2px rgba(191, 158, 118, 0.2); + font-size: 14px; + line-height: 1.6; + pointer-events: none; + transform: translateY(8px); + transition: + opacity 0.5s ease, + transform 0.5s ease; + } + + .waifu-tips.show { + opacity: 1; + transform: translateY(0); + } + + .waifu-tool { + position: absolute; + right: 10px; + bottom: 54px; + z-index: 22; + display: flex; + flex-flow: column; + gap: 6px; + opacity: 0; + color: #5b6c7d; + font-size: 16px; + pointer-events: auto; + transform: translateX(8px); + transition: + opacity 0.3s linear, + transform 0.3s ease; + } + + .waifu-tool span { + display: block; + width: 20px; + height: 20px; + cursor: pointer; + line-height: 20px; + text-align: center; + transition: + color 0.2s ease, + transform 0.2s ease; + + &:hover, + &:focus { + color: #34495e; + outline: 0; + transform: scale(1.12); + } + + &::before { + font-family: 'Font Awesome 7 Free'; + font-weight: 900; + } + } + + .fui-home::before { + content: '\f015'; + } + + .fui-chat::before { + content: '\f075'; + } + + .fui-bot::before { + content: '\f544'; + } + + .fui-eye::before { + content: '\f06e'; + } + + .fui-user::before { + content: '\f007'; + } + + .fui-photo::before { + content: '\f030'; + } + + .fui-info-circle::before { + content: '\f05a'; + } + + .fui-cross::before { + content: '\f00d'; + } + + .gptInput { + position: fixed; + bottom: 0; + left: 0; + z-index: 23; + display: flex !important; + width: 300px; + gap: 6px; + padding: 8px; + opacity: 0; + pointer-events: none; + transform: translateY(100%); + transition: + opacity 0.3s linear, + transform 0.3s ease; + } + + .gptInput.show { + opacity: 0.8; + pointer-events: auto; + transform: translateY(-6px); + } + + .gptInput input, + .gptInput button { + height: 30px; + border: 1px solid rgba(224, 186, 140, 0.62); + border-radius: 6px; + color: #32373c; + background: rgba(236, 217, 188, 0.88); + font-size: 13px; + line-height: 30px; + outline: 0; + box-shadow: 0 3px 15px 2px rgba(191, 158, 118, 0.18); + } + + .gptInput input { + min-width: 0; + flex: 1; + padding: 0 8px; + } + + .gptInput button { + flex: none; + padding: 0 10px; + cursor: pointer; + } + } + + &__live2d-canvas { + position: relative; + width: 280px; + height: 250px; pointer-events: auto; transform-origin: 50% 82%; will-change: transform; @@ -288,9 +450,9 @@ html.filter-darkness .kt-blog__primary::after { } -@media (max-width: 1199px) { +@media (max-width: 768px) { @include blog.block { - &__live2d-canvas { + &__live2d-widget { display: none; } }