diff --git a/README.md b/README.md index 66bb297..95cea95 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium - 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。 - 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。 - Argon motion、滚动几何、延迟、RAF 调度、DOM id/selector、hash anchor 和跨组件 ref 必须从 `src/factories/blogAnimationFactory.ts` 与 `src/factories/blogDomFactory.ts` 取值;组件和 Hook 不再散写行为层 id、裸 `requestAnimationFrame`、裸 `setTimeout` 或重复 timing。 +- Live2D 不依赖旧 WordPress `live-2d` 插件、`live2d-widgets`、随机 CDN fallback 模型或插件账号签名;`BlogLive2D` 只在桌面端读取 `VITE_BLOG_LIVE2D_MANIFEST_URL`,默认 `/api/blog/live2d/pio/v1/manifest.json`,并通过自托管 Pio manifest 加载官方 Cubism runtime。 - Modal 不强求一比一复刻线上 Argon 动画,打开/关闭 motion 与 `centered` 居中定位交给 antdv-next;外层按 `packages/@core/ui-kit/popup-ui/src/modal/modal.vue` 保留 Header/Content/Footer 三段能力、`p-0`、`max-height`、纵向 flex、Content `min-h-40` 滚动和 `px-5 py-4`/`p-3`/`p-2` 间距,但无 footer slot 时不渲染 footer,不搬 draggable/fullscreen/loading/footer 按钮等复杂能力,自有颜色只守 Blog 主题色和暗色可读性。 - Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/?adminPreview=1&articleId=`。 diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts new file mode 100644 index 0000000..46fec27 --- /dev/null +++ b/src/__tests__/BlogLive2D.spec.ts @@ -0,0 +1,194 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { fetchBlogLive2DManifest } from '@/api/live2dManifest'; +import BlogLive2D from '@/components/blog/BlogLive2D'; +import { mountOfficialPioRuntime } from '@/components/blog/live2d/officialRuntimeBridge'; + +const pioManifest = { + character: 'pio', + desktopOnly: true, + fallback: null, + model3: '/api/blog/live2d/pio/v1/pio.model3.json', + runtimeScript: '/api/blog/live2d/pio/v1/pio-runtime.js', + version: 'v1', +} as const; + +describe('fetchBlogLive2DManifest', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('accepts only the Pio manifest without fallback model chains', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })), + ); + + const manifest = await fetchBlogLive2DManifest('/manifest.json'); + + expect(manifest.character).toBe('pio'); + expect(manifest.fallback).toBeNull(); + expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/pio.model3.json'); + }); + + it('rejects non-Pio or fallback manifests before runtime loading', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + ...pioManifest, + character: 'haru', + fallback: 'https://cdn.example.com/model.json', + }), + { status: 200 }, + ), + ), + ); + + await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow( + 'Only Pio Live2D manifest is allowed.', + ); + }); + + it('rejects malformed manifest payloads before runtime loading', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(null), { status: 200 })), + ); + + await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow( + 'Only Pio Live2D manifest is allowed.', + ); + }); +}); + +describe('mountOfficialPioRuntime', () => { + afterEach(() => { + document.body.innerHTML = ''; + delete window.KtPioLive2D; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('loads official runtime script once and mounts with the manifest model URL', async () => { + const destroy = vi.fn(); + const runtime = { + mount: vi.fn(async () => ({ destroy })), + }; + const originalAppendChild = document.body.appendChild.bind(document.body); + vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { + const element = originalAppendChild(node); + if (element instanceof HTMLScriptElement) { + window.KtPioLive2D = runtime; + element.onload?.(new Event('load')); + } + return element; + }); + + const canvas = document.createElement('canvas'); + await mountOfficialPioRuntime(canvas, pioManifest); + await mountOfficialPioRuntime(canvas, pioManifest); + + expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1); + expect(runtime.mount).toHaveBeenCalledTimes(2); + expect(runtime.mount).toHaveBeenCalledWith({ + canvas, + model3: '/api/blog/live2d/pio/v1/pio.model3.json', + }); + }); + + it('coalesces concurrent runtime script loads before mounting', async () => { + const runtime = { + mount: vi.fn(async () => ({ destroy: vi.fn() })), + }; + const runtimeScripts: HTMLScriptElement[] = []; + const originalAppendChild = document.body.appendChild.bind(document.body); + vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { + const element = originalAppendChild(node); + if (element instanceof HTMLScriptElement) { + runtimeScripts.push(element); + } + return element; + }); + + const firstCanvas = document.createElement('canvas'); + const secondCanvas = document.createElement('canvas'); + const firstMount = mountOfficialPioRuntime(firstCanvas, pioManifest); + const secondMount = mountOfficialPioRuntime(secondCanvas, pioManifest); + await Promise.resolve(); + + expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1); + + window.KtPioLive2D = runtime; + expect(runtimeScripts).toHaveLength(1); + runtimeScripts[0]?.onload?.(new Event('load')); + + await Promise.all([firstMount, secondMount]); + + expect(runtime.mount).toHaveBeenCalledTimes(2); + expect(runtime.mount).toHaveBeenNthCalledWith(1, { + canvas: firstCanvas, + model3: '/api/blog/live2d/pio/v1/pio.model3.json', + }); + expect(runtime.mount).toHaveBeenNthCalledWith(2, { + canvas: secondCanvas, + model3: '/api/blog/live2d/pio/v1/pio.model3.json', + }); + }); +}); + +describe('BlogLive2D', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('does not fetch the manifest below the desktop viewport', async () => { + vi.stubGlobal('innerWidth', 1199); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + mount(BlogLive2D); + await flushPromises(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(document.querySelector('#live2d')).toBeNull(); + }); + + it('mounts Pio runtime on desktop and destroys it when unmounted', async () => { + vi.stubGlobal('innerWidth', 1280); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })), + ); + const destroy = vi.fn(); + vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { + const element = Node.prototype.appendChild.call(document.body, node); + if (element instanceof HTMLScriptElement) { + window.KtPioLive2D = { + mount: vi.fn(async () => ({ destroy })), + }; + element.onload?.(new Event('load')); + } + return element; + }); + + const wrapper = mount(BlogLive2D); + await flushPromises(); + + const canvas = wrapper.find('canvas#live2d'); + expect(canvas.exists()).toBe(true); + expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({ + canvas: canvas.element, + model3: '/api/blog/live2d/pio/v1/pio.model3.json', + }); + + wrapper.unmount(); + + expect(destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/api/live2dManifest.ts b/src/api/live2dManifest.ts new file mode 100644 index 0000000..ab7aa9f --- /dev/null +++ b/src/api/live2dManifest.ts @@ -0,0 +1,26 @@ +import type { BlogLive2DManifest } from '@/components/blog/live2d/types'; + +/** + * Loads the Pio-only Live2D manifest used by the official runtime bridge. + * @param manifestUrl Runtime manifest URL configured for the current Blog deployment. + * @returns Validated Pio manifest consumed by `BlogLive2D`. + */ +export async function fetchBlogLive2DManifest(manifestUrl: string): Promise { + const response = await fetch(manifestUrl, { credentials: 'omit' }); + if (!response.ok) { + throw new Error(`Live2D manifest failed: ${response.status}`); + } + + const manifest = (await response.json()) as Partial | null; + if ( + !manifest || + manifest.character !== 'pio' || + manifest.fallback !== null || + typeof manifest.model3 !== 'string' || + typeof manifest.runtimeScript !== 'string' + ) { + throw new Error('Only Pio Live2D manifest is allowed.'); + } + + return manifest as BlogLive2DManifest; +} diff --git a/src/components/blog/BlogLive2D.tsx b/src/components/blog/BlogLive2D.tsx index 2b4d136..308fc30 100644 --- a/src/components/blog/BlogLive2D.tsx +++ b/src/components/blog/BlogLive2D.tsx @@ -1,208 +1,53 @@ -import { defineComponent, onBeforeUnmount, onMounted } from 'vue'; +import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue'; -import { - BLOG_ANIMATION_TIMING_MS, - BLOG_VIEWPORT_GEOMETRY, - clearBlogDelay, - runAfterBlogDelay, -} from '@/factories/blogAnimationFactory'; -import { BLOG_LIVE2D_SCRIPT_IDS, blogDomId, blogDomSelector, blogDomSelectorFromId } from '@/factories/blogDomFactory'; +import { fetchBlogLive2DManifest } from '@/api/live2dManifest'; +import { BLOG_VIEWPORT_GEOMETRY } from '@/factories/blogAnimationFactory'; +import { blogDomId } from '@/factories/blogDomFactory'; -const LIVE2D_ASSET_BASE = 'https://blog.kwitsukasa.top/wp-content/plugins/live-2d/assets'; -const LIVE2D_MODEL_API = 'https://live2d.fghrsh.net/api/'; -const LIVE2D_SCRIPT_IDS = [ - [BLOG_LIVE2D_SCRIPT_IDS.live2dV1Core, `${LIVE2D_ASSET_BASE}/live2dv1.min.js?ver=2.2.1`, true], - [BLOG_LIVE2D_SCRIPT_IDS.live2dV2Core, `${LIVE2D_ASSET_BASE}/cubism-core/live2dcubismcore.min.js?ver=2.2.1`, false], - [BLOG_LIVE2D_SCRIPT_IDS.live2dV2Sdk, `${LIVE2D_ASSET_BASE}/live2dv2.min.js?ver=2.2.1`, true], - [BLOG_LIVE2D_SCRIPT_IDS.live2dWeb, `${LIVE2D_ASSET_BASE}/live2dwebsdk.min.js?ver=2.2.1`, true], -] as const; +import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge'; +import type { KtPioLive2DRuntimeHandle } from './live2d/types'; -declare global { - interface Window { - initLive2dWeb?: () => void; - live2d_settings?: Record; - } -} +const LIVE2D_MANIFEST_URL = import.meta.env.VITE_BLOG_LIVE2D_MANIFEST_URL || '/api/blog/live2d/pio/v1/manifest.json'; export default defineComponent({ name: 'BlogLive2D', + /** + * Owns the Pio canvas lifecycle and defers all drawing to the official runtime bridge. + * @returns Render function that only creates the canvas on desktop viewports. + */ setup() { - let fallbackTimer: number | null = null; + const canvasRef = ref(null); + const isDesktop = window.innerWidth >= BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx; + let disposed = false; + let runtimeHandle: KtPioLive2DRuntimeHandle | null = null; - onMounted(() => { - if (window.innerWidth < BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx) { + onMounted(async () => { + if (!isDesktop || !canvasRef.value) { return; } - installLive2d() - .then(() => { - fallbackTimer = runAfterBlogDelay(ensureFallbackLive2dDom, BLOG_ANIMATION_TIMING_MS.live2dFallbackWarmup); - }) - .catch(() => { - ensureFallbackLive2dDom(); - }); + try { + const manifest = await fetchBlogLive2DManifest(LIVE2D_MANIFEST_URL); + const mountedHandle = await mountOfficialPioRuntime(canvasRef.value, manifest); + if (disposed) { + mountedHandle.destroy(); + return; + } + runtimeHandle = mountedHandle; + } catch (error: unknown) { + console.warn('[KT Blog] Pio Live2D unavailable.', error); + } }); onBeforeUnmount(() => { - clearBlogDelay(fallbackTimer); - document.querySelector(blogDomSelector('live2dFallback'))?.remove(); + disposed = true; + runtimeHandle?.destroy(); + runtimeHandle = null; }); - return () => null; + return () => + isDesktop ? ( + + ) : null; }, }); - -/** - * Loads and initializes the same public Live2D Web plugin assets used by the live WordPress Argon page. - * - * The local Vue app owns only the mounting lifecycle; rendering remains delegated to the - * upstream Live2D scripts and the public model API so the visible widget is not a hand-drawn placeholder. - */ -async function installLive2d() { - installLive2dSettings(); - appendLive2dStyle(); - - for (const [id, src, isModule] of LIVE2D_SCRIPT_IDS) { - await appendScriptOnce(id, src, isModule); - } - - window.initLive2dWeb?.(); -} - -/** - * Mirrors the live site's non-secret Live2D settings required for model selection and widget geometry. - */ -function installLive2dSettings() { - window.live2d_settings = { - currentPage: { - get_the_id: 61, - is_home: true, - is_single: false, - }, - localPath: `${LIVE2D_ASSET_BASE.replace('/assets', '')}/model`, - settings: { - aboutPageUrl: '#', - aiProvider: 'live2dweb', - apiType: false, - canCloseLive2d: true, - canSwitchHitokoto: true, - canSwitchModel: true, - canSwitchTextures: true, - canTakeScreenshot: true, - canTurnToAboutPage: true, - canTurnToHomePage: true, - hitokotoAPI: 'lwl12.com', - homePageUrl: 'https://blog.kwitsukasa.top', - isBotButton: true, - live2dLayoutType: true, - modelAPI: LIVE2D_MODEL_API, - modelId: '1', - modelPoint: { - x: 0, - y: 0, - zoom: '1.0', - }, - modelRandMode: 'rand', - modelStorage: true, - modelTexturesId: '53', - modelTexturesRandMode: 'switch', - protectV2: 'direct', - screenshotCaptureName: 'live2d.png', - showF12Message: true, - showF12OpenMsg: true, - showF12Status: true, - showHitokoto: true, - showToolMenu: true, - waifuDraggable: 'axis-x', - waifuDraggableRevert: true, - waifuEdgeSide: 'left', - waifuEdgeSize: 0, - waifuFontSize: 12, - waifuMinWidth: 768, - waifuSize: { - height: 250, - width: 280, - }, - waifuTipsSize: { - height: 70, - width: 250, - }, - }, - userInfo: { - certserialnumber: 0, - sign: '', - userName: '', - }, - waifuTips: { - click_msg: ['是...是不小心碰到了吧'], - click_selector: '.waifu #live2d', - console_open_msg: ['哈哈,你打开了控制台,是想要看看我的秘密吗?'], - copy_message: ['转载要记得加上出处哦!'], - hidden_message: ['我们还能再见面的吧...?'], - hour_tips: [['default', '嗨~ 快来逗我玩吧!']], - load_rand_textures: ['我的新衣服好看嘛?'], - mouseover_msg: [], - referrer_message: [['none', '欢迎阅读『{title}』']], - screenshot_message: ['照好了嘛,是不是很可爱呢?'], - }, - }; -} - -/** - * Adds the Live2D stylesheet once so the SDK-created DOM uses the same geometry as WordPress. - */ -function appendLive2dStyle() { - if (document.querySelector(blogDomSelector('live2dStyle'))) { - return; - } - - const styleLink = document.createElement('link'); - styleLink.id = blogDomId('live2dStyle'); - styleLink.rel = 'stylesheet'; - styleLink.href = `${LIVE2D_ASSET_BASE}/waifu.css?ver=2.2.1`; - document.head.appendChild(styleLink); -} - -/** - * @param id Stable script id used by the live WordPress plugin. - * @param src Public script URL to load once. - * @param isModule Whether the upstream script is loaded as an ES module on the live site. - */ -function appendScriptOnce(id: string, src: string, isModule: boolean) { - return new Promise((resolve, reject) => { - if (document.querySelector(blogDomSelectorFromId(id))) { - resolve(); - return; - } - - const script = document.createElement('script'); - script.id = id; - script.src = src; - if (isModule) { - script.type = 'module'; - } - script.onload = () => resolve(); - script.onerror = () => reject(new Error(`Live2D script failed: ${src}`)); - document.body.appendChild(script); - }); -} - -/** - * Creates the same visible DOM contract as WordPress when the SDK is unavailable or still warming up. - */ -function ensureFallbackLive2dDom() { - if (document.querySelector(blogDomSelector('live2dCanvas'))) { - return; - } - - const root = document.createElement('div'); - root.id = blogDomId('live2dFallback'); - root.className = 'waifu kt-blog__live2d-fallback'; - root.innerHTML = ` - - - - - `; - document.body.appendChild(root); -} diff --git a/src/components/blog/live2d/officialRuntimeBridge.ts b/src/components/blog/live2d/officialRuntimeBridge.ts new file mode 100644 index 0000000..e127a9e --- /dev/null +++ b/src/components/blog/live2d/officialRuntimeBridge.ts @@ -0,0 +1,71 @@ +import type { + BlogLive2DManifest, + KtPioLive2DRuntime, + KtPioLive2DRuntimeHandle, +} from './types'; + +const RUNTIME_SCRIPT_ID = 'kt-blog-pio-live2d-runtime'; +let runtimeScriptPromise: Promise | null = null; + +declare global { + interface Window { + KtPioLive2D?: KtPioLive2DRuntime; + } +} + +/** + * Loads the generated official Cubism runtime script and mounts the Pio model. + * @param canvas Canvas element owned by the Vue component. + * @param manifest Validated Pio manifest with runtime script and model URL. + * @returns Runtime handle used by the component teardown path. + */ +export async function mountOfficialPioRuntime( + canvas: HTMLCanvasElement, + manifest: BlogLive2DManifest, +): Promise { + await appendRuntimeScript(manifest.runtimeScript); + if (!window.KtPioLive2D) { + throw new Error('KtPioLive2D runtime was not registered.'); + } + + return window.KtPioLive2D.mount({ canvas, model3: manifest.model3 }); +} + +/** + * Appends the official Cubism runtime wrapper only once per document. + * @param src Versioned runtime script URL generated from the self-hosted Pio package. + * @returns Promise that resolves after the script is available to register `window.KtPioLive2D`. + */ +async function appendRuntimeScript(src: string): Promise { + if (runtimeScriptPromise) { + return runtimeScriptPromise; + } + + if (document.getElementById(RUNTIME_SCRIPT_ID)) { + return; + } + + let resolveLoad: () => void = () => undefined; + let rejectLoad: (error: Error) => void = () => undefined; + runtimeScriptPromise = new Promise((resolve, reject) => { + resolveLoad = resolve; + rejectLoad = reject; + }); + + const script = document.createElement('script'); + script.id = RUNTIME_SCRIPT_ID; + script.src = src; + script.async = true; + script.onload = () => { + runtimeScriptPromise = null; + resolveLoad(); + }; + script.onerror = () => { + runtimeScriptPromise = null; + script.remove(); + rejectLoad(new Error(`Live2D runtime failed: ${src}`)); + }; + document.body.appendChild(script); + + return runtimeScriptPromise; +} diff --git a/src/components/blog/live2d/types.ts b/src/components/blog/live2d/types.ts new file mode 100644 index 0000000..c4905f9 --- /dev/null +++ b/src/components/blog/live2d/types.ts @@ -0,0 +1,27 @@ +export interface BlogLive2DManifest { + character: 'pio'; + desktopOnly: boolean; + fallback: null; + integrity?: { + manifestSha256?: string; + }; + model3: string; + runtimeScript: string; + version: string; +} + +export interface KtPioLive2DRuntimeHandle { + /** + * Releases canvas bindings, animation frames, and WebGL/runtime resources owned by the mounted Pio model. + */ + destroy(): void; +} + +export interface KtPioLive2DRuntime { + /** + * Mounts the self-hosted Pio model into the canvas controlled by `BlogLive2D`. + * @param options Canvas and model URL emitted by the validated manifest. + * @returns Runtime handle used by Vue teardown to release rendering resources. + */ + mount(options: { canvas: HTMLCanvasElement; model3: string }): Promise; +} diff --git a/src/factories/blogAnimationFactory.ts b/src/factories/blogAnimationFactory.ts index 3f6486b..820b14a 100644 --- a/src/factories/blogAnimationFactory.ts +++ b/src/factories/blogAnimationFactory.ts @@ -2,7 +2,6 @@ export const BLOG_ANIMATION_TIMING_MS = { catalogManualLock: 400, catalogNormal: 400, floatSideUnload: 300, - live2dFallbackWarmup: 1500, scrollToTop: 800, toastVisible: 5000, } as const; @@ -27,7 +26,7 @@ export const BLOG_SCROLL_GEOMETRY = { } as const; export const BLOG_VIEWPORT_GEOMETRY = { - live2dDesktopMinWidthPx: 768, + live2dDesktopMinWidthPx: 1200, } as const; export const BLOG_MOTION_CSS_VARS = { diff --git a/src/factories/blogDomFactory.ts b/src/factories/blogDomFactory.ts index 035c4c8..db760d2 100644 --- a/src/factories/blogDomFactory.ts +++ b/src/factories/blogDomFactory.ts @@ -29,11 +29,6 @@ export const BLOG_DOM_IDS = { leftbarSearchContainer: 'leftbar_search_container', leftbarSearchInput: 'leftbar_search_input', live2dCanvas: 'live2d', - live2dChatText: 'live2dChatText', - live2dFallback: 'kt-blog-live2d-fallback', - live2dSend: 'live2dSend', - live2dSendClose: 'live2dSendClose', - live2dStyle: 'waifu_css-css', main: 'main', openSidebar: 'open_sidebar', postArticleTitle: 'post_article_title', @@ -63,13 +58,6 @@ export const BLOG_META_NAMES = { themeColorRgb: 'theme-color-rgb', } as const; -export const BLOG_LIVE2D_SCRIPT_IDS = { - live2dV1Core: 'live2dv1core-js', - live2dV2Core: 'live2dv2core-js', - live2dV2Sdk: 'live2dv2sdk-js', - live2dWeb: 'live2dweb-js', -} as const; - export type BlogDomIdKey = keyof typeof BLOG_DOM_IDS; /** diff --git a/src/styles/base.scss b/src/styles/base.scss index 3018bd1..7a6f1f0 100644 --- a/src/styles/base.scss +++ b/src/styles/base.scss @@ -274,42 +274,22 @@ html.filter-darkness .kt-blog__primary::after { margin-bottom: 1rem; } - &__live2d-fallback { + &__live2d-canvas { position: fixed; - bottom: 0; - left: 0; - z-index: 80; - width: 280px; - height: 250px; - pointer-events: none; + right: 16px; + bottom: 76px; + z-index: 30; + width: 220px; + height: 320px; + pointer-events: auto; + } - #live2d { - width: 280px; - height: 250px; - } +} - #live2dChatText, - #live2dSend, - #live2dSendClose { - position: absolute; - bottom: 0; - pointer-events: auto; - } - - #live2dChatText { - left: 111px; - width: 6px; - height: 29px; - } - - #live2dSend { - left: 116px; - height: 29px; - } - - #live2dSendClose { - left: 166px; - bottom: 7px; +@media (max-width: 1199px) { + @include blog.block { + &__live2d-canvas { + display: none; } } }