diff --git a/packages/live2d/package.json b/packages/live2d/package.json index 3be2bc1..5c39e65 100644 --- a/packages/live2d/package.json +++ b/packages/live2d/package.json @@ -27,6 +27,7 @@ "@pixi/sound": "^6.0.1", "iconify-icon": "^3.0.2", "lit": "^3.3.3", + "markdown-it": "^14.2.0", "pixi.js": "^8.13.1", "query-string": "^9.3.1", "react": "^19.2.6", @@ -34,6 +35,7 @@ "untitled-pixi-live2d-engine": "^1.1.0" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@unocss/postcss": "^66.6.8", diff --git a/packages/live2d/src/components/Live2dTips.tsx b/packages/live2d/src/components/Live2dTips.tsx index 6cddefe..dd3e68a 100644 --- a/packages/live2d/src/components/Live2dTips.tsx +++ b/packages/live2d/src/components/Live2dTips.tsx @@ -10,6 +10,10 @@ import type { StreamMessageStartEvent, StreamMessageStopEvent, } from "@/live2d/events/stream-message"; +import { + hasMarkdownBlockElements, + renderMarkdown, +} from "@/live2d/helpers/renderMarkdown"; import { isNotEmpty } from "@/live2d/utils/isNotEmpty"; import { randomSelection } from "@/live2d/utils/randomSelection"; import { consume } from "@lit/context"; @@ -21,6 +25,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; export class Live2dTips extends UnoLitElement { private static readonly DEFAULT_BOTTOM_OFFSET = 250; private static readonly MODEL_OVERLAP_OFFSET = 20; + private static readonly OPACITY_TRANSITION_MS = 1000; @consume({ context: configContext }) @property({ attribute: false }) @@ -34,8 +39,10 @@ export class Live2dTips extends UnoLitElement { private _bottomOffset = Live2dTips.DEFAULT_BOTTOM_OFFSET; private priority = -1; private messageTimer: number | null = null; + private streamModeResetTimer: number | null = null; private streamInactivityTimeout = 60_000; // 流式消息模式标志 + @state() private isStreamMode = false; private readonly onMessage = (event: Event) => { this.handleMessage(event as SendMessageEvent); @@ -60,6 +67,11 @@ export class Live2dTips extends UnoLitElement { } render(): TemplateResult { + const renderedMessage = this.isStreamMode + ? renderMarkdown(this._message) + : this._message; + const isMarkdownBlock = + this.isStreamMode && hasMarkdownBlockElements(renderedMessage); const classes = { "animate-shake": true, "animate-delay-5s": true, @@ -78,13 +90,14 @@ export class Live2dTips extends UnoLitElement { "text-ellipsis": true, "transition-opacity-1000": true, "break-all": true, + "live2d-tips-markdown": isMarkdownBlock, "opacity-100": this._isShow, "opacity-0": !this._isShow, "select-none": true, }; return html`
- ${unsafeHTML(this._message)} + ${unsafeHTML(renderedMessage)}
`; } @@ -110,6 +123,7 @@ export class Live2dTips extends UnoLitElement { window.removeEventListener("live2d:stream-message-stop", this.onStreamStop); window.removeEventListener("live2d:model-layout", this.onModelLayout); this.clearMessageTimer(); + this.clearStreamModeResetTimer(); } handleMessage(e: SendMessageEvent): void { @@ -125,11 +139,13 @@ export class Live2dTips extends UnoLitElement { return; } this.clearMessageTimer(); + this.clearStreamModeResetTimer(); const message = randomSelection(text); if (!isNotEmpty(message)) { return; } this.priority = priority; + this.isStreamMode = false; this._message = message; this._isShow = true; this.messageTimer = setTimeout(() => { @@ -144,6 +160,7 @@ export class Live2dTips extends UnoLitElement { handleStreamStart(e: StreamMessageStartEvent): void { const { timeout } = e.detail; const STREAM_PRIORITY = 99999; + this.clearStreamModeResetTimer(); this.priority = STREAM_PRIORITY; this.isStreamMode = true; this.streamInactivityTimeout = timeout; @@ -186,7 +203,7 @@ export class Live2dTips extends UnoLitElement { this.messageTimer = setTimeout(() => { this._isShow = false; this.priority = -1; - this.isStreamMode = false; + this.scheduleStreamModeReset(); }, showTimeout); } @@ -215,11 +232,26 @@ export class Live2dTips extends UnoLitElement { () => { this._isShow = false; this.priority = -1; - this.isStreamMode = false; + this.scheduleStreamModeReset(); }, timeout ?? Number(this.config?.chunkTimeout || 60) * 1000, ); } + + private scheduleStreamModeReset(): void { + this.clearStreamModeResetTimer(); + this.streamModeResetTimer = setTimeout(() => { + this.isStreamMode = false; + this.streamModeResetTimer = null; + }, Live2dTips.OPACITY_TRANSITION_MS); + } + + private clearStreamModeResetTimer(): void { + if (this.streamModeResetTimer) { + clearTimeout(this.streamModeResetTimer); + this.streamModeResetTimer = null; + } + } } customElements.define("live2d-tips", Live2dTips); diff --git a/packages/live2d/src/helpers/__tests__/createStreamMessage.test.ts b/packages/live2d/src/helpers/__tests__/createStreamMessage.test.ts new file mode 100644 index 0000000..5f04106 --- /dev/null +++ b/packages/live2d/src/helpers/__tests__/createStreamMessage.test.ts @@ -0,0 +1,27 @@ +import { + STREAM_MESSAGE_START_EVENT_NAME, + type StreamMessageStartEventDetail, +} from "@/live2d/events/stream-message"; +import { describe, expect, it, vi } from "vitest"; +import { createStreamMessage } from "../createStreamMessage"; + +describe("createStreamMessage", () => { + it("dispatches stream start with the inactivity timeout", () => { + const listener = vi.fn((event: Event) => { + const detail = (event as CustomEvent) + .detail; + expect(detail).toEqual({ + timeout: 1000, + }); + }); + window.addEventListener(STREAM_MESSAGE_START_EVENT_NAME, listener); + + try { + createStreamMessage(1000, 2000); + } finally { + window.removeEventListener(STREAM_MESSAGE_START_EVENT_NAME, listener); + } + + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/live2d/src/helpers/__tests__/renderMarkdown.test.ts b/packages/live2d/src/helpers/__tests__/renderMarkdown.test.ts new file mode 100644 index 0000000..0e97629 --- /dev/null +++ b/packages/live2d/src/helpers/__tests__/renderMarkdown.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { hasMarkdownBlockElements, renderMarkdown } from "../renderMarkdown"; + +describe("renderMarkdown", () => { + it("renders common markdown blocks and inline marks", () => { + const html = renderMarkdown( + [ + "## 标题", + "", + "这里有 **重点**、`code` 和 [链接](https://example.com?a=1&b=2)。", + "", + "- 第一项", + "- 第二项", + ].join("\n"), + ); + + expect(html).toContain("

标题

"); + expect(html).toContain("重点"); + expect(html).toContain("code"); + expect(html).toContain( + '链接', + ); + expect(html).toContain("