feat: 对齐Pio WordPress插件外壳
This commit is contained in:
parent
445a09782b
commit
0cdc68a79f
123
e2e/live2d-wordpress-widget.spec.ts
Normal file
123
e2e/live2d-wordpress-widget.spec.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
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<HTMLCanvasElement>('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);
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<void> | null = null;
|
||||
let runtimeMountPromise: Promise<WordPressLive2DRuntimeHandle> | 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<WordPressLive2DRuntimeHandle> {
|
||||
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<HTMLElement>(`.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<WordPressLive2DRuntimeHandle> {
|
||||
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.
|
||||
|
||||
34
src/components/blog/live2d/wordpressWidgetConfig.ts
Normal file
34
src/components/blog/live2d/wordpressWidgetConfig.ts
Normal file
@ -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;
|
||||
186
src/components/blog/live2d/wordpressWidgetController.ts
Normal file
186
src/components/blog/live2d/wordpressWidgetController.ts
Normal file
@ -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<HTMLElement>('[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);
|
||||
},
|
||||
};
|
||||
}
|
||||
12
src/components/blog/live2d/wordpressWidgetMessages.ts
Normal file
12
src/components/blog/live2d/wordpressWidgetMessages.ts
Normal file
@ -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;
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user