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;
}
}