@linxin666/dsh-pet 0.1.20 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -10
- package/README.zh.md +17 -10
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +158 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +853 -41
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
- package/lib/types/affinity.d.ts +4 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +32 -6
- package/lib/types/chatter.d.ts +111 -0
- package/lib/types/chatter.d.ts.map +1 -0
- package/lib/types/chatter.js +666 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +88 -14
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/sequences.d.ts +10 -0
- package/lib/types/client/sequences.d.ts.map +1 -0
- package/lib/types/client/sequences.js +20 -0
- package/lib/types/event-projection.d.ts +14 -1
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +38 -17
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +15 -7
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +36 -0
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +5 -0
- package/lib/types/service.d.ts +6 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +15 -2
- package/lib/types/state.d.ts +7 -4
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +20 -20
- package/package.json +9 -9
- package/src/affinity.test.ts +4 -2
- package/src/affinity.ts +37 -6
- package/src/chatter.test.ts +154 -0
- package/src/chatter.ts +697 -0
- package/src/client/PetSprite.test.tsx +190 -1
- package/src/client/PetSprite.tsx +112 -25
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/pet-css.test.ts +42 -0
- package/src/client/pet.module.css +154 -26
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +50 -3
- package/src/registry.ts +47 -3
- package/src/remarks.ts +6 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* selection) as composition input, never as submit/cancel (issue #89).
|
|
6
6
|
*/
|
|
7
7
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
8
|
-
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
8
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
9
9
|
import { PetSprite, type PetSpriteProps } from './PetSprite.tsx'
|
|
10
10
|
import { t } from './locales.ts'
|
|
11
11
|
import type { PetStateView } from '../service.ts'
|
|
@@ -27,6 +27,7 @@ function petDefinition(): PetDefinition {
|
|
|
27
27
|
cell: { width: 192, height: 208 },
|
|
28
28
|
columns: 8,
|
|
29
29
|
rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
30
|
+
atlasRows: 9,
|
|
30
31
|
tracks: {
|
|
31
32
|
idle: track([0, 1, 2, 3, 4, 5], [400, 400, 400, 400, 400, 400]),
|
|
32
33
|
'running-right': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
|
|
@@ -86,6 +87,7 @@ beforeAll(() => {
|
|
|
86
87
|
|
|
87
88
|
afterEach(() => {
|
|
88
89
|
cleanup()
|
|
90
|
+
vi.restoreAllMocks()
|
|
89
91
|
})
|
|
90
92
|
|
|
91
93
|
/** Render the pet with mocked callbacks; returns the rename and open spys. */
|
|
@@ -176,6 +178,131 @@ describe('PetSprite rename input', () => {
|
|
|
176
178
|
expect(onRename).not.toHaveBeenCalled()
|
|
177
179
|
expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
|
|
178
180
|
})
|
|
181
|
+
|
|
182
|
+
it('ignores Enter between compositionStart and compositionEnd even when isComposing is false (#303)', () => {
|
|
183
|
+
// WeChat IME (Windows) marks composition keydowns with isComposing ===
|
|
184
|
+
// false; only the explicit composition events can be trusted.
|
|
185
|
+
const { onRename } = renderPet()
|
|
186
|
+
const input = openRename()
|
|
187
|
+
fireEvent.change(input, { target: { value: '泡泡酱' } })
|
|
188
|
+
fireEvent.compositionStart(input)
|
|
189
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
190
|
+
expect(onRename).not.toHaveBeenCalled()
|
|
191
|
+
expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
|
|
192
|
+
fireEvent.compositionEnd(input)
|
|
193
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
194
|
+
expect(onRename).toHaveBeenCalledWith('泡泡酱')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("ignores 'Process' keydowns emitted by IMEs mid-composition (#303)", () => {
|
|
198
|
+
const { onRename } = renderPet()
|
|
199
|
+
const input = openRename()
|
|
200
|
+
fireEvent.change(input, { target: { value: '泡泡酱' } })
|
|
201
|
+
fireEvent.keyDown(input, { key: 'Process' })
|
|
202
|
+
expect(onRename).not.toHaveBeenCalled()
|
|
203
|
+
expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('keeps the rename panel open when the pointer leaves mid-rename (#303)', () => {
|
|
207
|
+
// An IME candidate window is an OS-level window: moving the pointer onto
|
|
208
|
+
// it fires pointerleave on the float. The hide timer must not unmount
|
|
209
|
+
// the input mid-composition (that crashes some IMEs / the renderer).
|
|
210
|
+
vi.useFakeTimers()
|
|
211
|
+
try {
|
|
212
|
+
renderPet()
|
|
213
|
+
const input = openRename()
|
|
214
|
+
const float = input.parentElement?.parentElement?.parentElement
|
|
215
|
+
expect(float).not.toBeNull()
|
|
216
|
+
fireEvent.pointerOut(float!, { relatedTarget: null })
|
|
217
|
+
act(() => { vi.advanceTimersByTime(1000) })
|
|
218
|
+
expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
|
|
219
|
+
// After the rename ends, hover behavior works again.
|
|
220
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
221
|
+
fireEvent.pointerOut(float!, { relatedTarget: null })
|
|
222
|
+
act(() => { vi.advanceTimersByTime(1000) })
|
|
223
|
+
expect(screen.queryByText('改名')).toBeNull()
|
|
224
|
+
} finally {
|
|
225
|
+
vi.useRealTimers()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('PetSprite hover panel placement', () => {
|
|
231
|
+
it('places the panel above when an old saved position leaves no room below', () => {
|
|
232
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
|
233
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
234
|
+
if (this.style.bottom !== '') {
|
|
235
|
+
return { top: 720, right: 1256, bottom: 880, left: 1108, width: 148, height: 160, x: 1108, y: 720, toJSON: () => ({}) }
|
|
236
|
+
}
|
|
237
|
+
return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
renderPet({ display: { ...snapshot.display, bottom: 20 } })
|
|
241
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
242
|
+
|
|
243
|
+
const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
|
|
244
|
+
expect(panel.getAttribute('data-placement')).toBe('above')
|
|
245
|
+
// No bubble above the sprite: the panel keeps its default 8px gap.
|
|
246
|
+
expect(panel.style.marginBottom).toBe('')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('lifts the above-panel clear of the status bubble instead of overlapping it', () => {
|
|
250
|
+
// Regression: the fallback above-placement anchored the panel at the
|
|
251
|
+
// sprite's top edge — the exact region the status bubble occupies — so
|
|
252
|
+
// the panel covered the bubble. The panel must now ride above it.
|
|
253
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
|
254
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
255
|
+
if (this.className.includes('bubbleStack')) {
|
|
256
|
+
// The bubble area (stack wrapping the status bubble): 40px tall.
|
|
257
|
+
return { top: 650, right: 1200, bottom: 690, left: 1120, width: 80, height: 40, x: 1120, y: 650, toJSON: () => ({}) }
|
|
258
|
+
}
|
|
259
|
+
// Sprite (bottom 974 of a 900px viewport) and panel (86px tall).
|
|
260
|
+
return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
renderPet({
|
|
264
|
+
snapshot: { ...snapshot, bubble: '正在思考' },
|
|
265
|
+
display: { ...snapshot.display, bottom: 20 },
|
|
266
|
+
})
|
|
267
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
268
|
+
|
|
269
|
+
const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
|
|
270
|
+
expect(panel.getAttribute('data-placement')).toBe('above')
|
|
271
|
+
// 40px bubble height + 14px (8px base gap + 6px clearance).
|
|
272
|
+
expect(panel.style.marginBottom).toBe('54px')
|
|
273
|
+
// The bubble stays rendered while the lifted panel is open.
|
|
274
|
+
expect(screen.queryByText('正在思考')).not.toBeNull()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('lifts the above-panel clear of the session bubble stack', () => {
|
|
278
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
|
279
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
280
|
+
// The bubble stack contains buttons, not role=status; spot it by the
|
|
281
|
+
// session bubble text content being present among its descendants.
|
|
282
|
+
if (this.textContent === '正在思考正在使用 grep') {
|
|
283
|
+
// Two stacked bubbles: 88px tall.
|
|
284
|
+
return { top: 600, right: 1200, bottom: 688, left: 1100, width: 100, height: 88, x: 1100, y: 600, toJSON: () => ({}) }
|
|
285
|
+
}
|
|
286
|
+
return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
renderPet({
|
|
290
|
+
snapshot: {
|
|
291
|
+
...snapshot,
|
|
292
|
+
sessions: [
|
|
293
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
294
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
display: { ...snapshot.display, bottom: 20 },
|
|
298
|
+
})
|
|
299
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
300
|
+
|
|
301
|
+
const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
|
|
302
|
+
expect(panel.getAttribute('data-placement')).toBe('above')
|
|
303
|
+
// 88px stack height + 14px clearance.
|
|
304
|
+
expect(panel.style.marginBottom).toBe('102px')
|
|
305
|
+
})
|
|
179
306
|
})
|
|
180
307
|
|
|
181
308
|
describe('PetSprite status bubble', () => {
|
|
@@ -217,6 +344,36 @@ describe('PetSprite status bubble', () => {
|
|
|
217
344
|
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
218
345
|
})
|
|
219
346
|
|
|
347
|
+
it('renders the inner whisper beneath the status bubble', () => {
|
|
348
|
+
renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
|
|
349
|
+
// The whisper shows alongside (not instead of) the status copy.
|
|
350
|
+
expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
|
|
351
|
+
expect(screen.queryByText('正在思考')).not.toBeNull()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('renders the whisper alongside the session bubble stack', () => {
|
|
355
|
+
renderPet({
|
|
356
|
+
snapshot: {
|
|
357
|
+
...workingSnapshot,
|
|
358
|
+
whisper: '我在这儿陪着你呢,别急别急',
|
|
359
|
+
sessions: [
|
|
360
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
|
|
365
|
+
expect(screen.queryByText('正在思考')).not.toBeNull()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('lets transient interaction feedback take over the whisper too', () => {
|
|
369
|
+
renderPet({
|
|
370
|
+
snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
|
|
371
|
+
feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
|
|
372
|
+
})
|
|
373
|
+
expect(screen.queryByText('摸摸成功')).not.toBeNull()
|
|
374
|
+
expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).toBeNull()
|
|
375
|
+
})
|
|
376
|
+
|
|
220
377
|
it('lets feedback replace the whole session bubble stack', () => {
|
|
221
378
|
renderPet({
|
|
222
379
|
snapshot: {
|
|
@@ -293,4 +450,36 @@ describe('PetSprite definition-driven render', () => {
|
|
|
293
450
|
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
294
451
|
expect(screen.queryByText('泡泡')).not.toBeNull()
|
|
295
452
|
})
|
|
453
|
+
|
|
454
|
+
it('advances a configured scene sequence after the current track duration', () => {
|
|
455
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
456
|
+
matches: false,
|
|
457
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
458
|
+
onchange: null,
|
|
459
|
+
addEventListener: () => {},
|
|
460
|
+
removeEventListener: () => {},
|
|
461
|
+
addListener: () => {},
|
|
462
|
+
removeListener: () => {},
|
|
463
|
+
dispatchEvent: () => false,
|
|
464
|
+
})
|
|
465
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
466
|
+
let nextFrame: FrameRequestCallback | undefined
|
|
467
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
468
|
+
nextFrame = callback
|
|
469
|
+
return 1
|
|
470
|
+
})
|
|
471
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
472
|
+
const definition = petDefinition()
|
|
473
|
+
definition.sequences = {
|
|
474
|
+
thinking: ['running', 'waiting', 'running', 'waiting', 'running'],
|
|
475
|
+
}
|
|
476
|
+
renderPet({
|
|
477
|
+
definition,
|
|
478
|
+
snapshot: { ...snapshot, animation: 'running', phase: 'thinking' },
|
|
479
|
+
})
|
|
480
|
+
const sprite = screen.getByRole('button', { name: '鲸鱼娘' })
|
|
481
|
+
expect(sprite.style.backgroundPosition).toBe('0px -1120px')
|
|
482
|
+
act(() => { nextFrame?.(1_500) })
|
|
483
|
+
expect(sprite.style.backgroundPosition).toBe('0px -960px')
|
|
484
|
+
})
|
|
296
485
|
})
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* @module @linxin666/dsh-pet/client/PetSprite
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { useEffect, useRef, useState } from 'react'
|
|
13
|
-
import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
13
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
|
|
14
14
|
import { createPortal } from 'react-dom'
|
|
15
15
|
import clsx from 'clsx'
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
@@ -19,7 +19,8 @@ import type { PetStateView } from '../service.ts'
|
|
|
19
19
|
import type { PetDefinition } from '../registry.ts'
|
|
20
20
|
import type { PetFeedback } from './pet-store.ts'
|
|
21
21
|
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
|
|
22
|
-
import
|
|
22
|
+
import { sequenceFrameAt } from './sequences.ts'
|
|
23
|
+
import { animationForPhase, type PetAnimation } from '../state.ts'
|
|
23
24
|
import { NS } from './locales.ts'
|
|
24
25
|
import styles from './pet.module.css'
|
|
25
26
|
|
|
@@ -66,10 +67,22 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
66
67
|
const { snapshot, definition, display, feedback } = props
|
|
67
68
|
const spriteRef = useRef<HTMLDivElement | null>(null)
|
|
68
69
|
const floatRef = useRef<HTMLDivElement | null>(null)
|
|
70
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
71
|
+
// Whichever bubble surface is currently rendered (feedback, the session
|
|
72
|
+
// stack, or the legacy status bubble) — only one exists at a time.
|
|
73
|
+
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
|
69
74
|
const [imageReady, setImageReady] = useState(false)
|
|
70
75
|
const [hovered, setHovered] = useState(false)
|
|
71
76
|
const [renaming, setRenaming] = useState(false)
|
|
77
|
+
const [panelAbove, setPanelAbove] = useState(false)
|
|
78
|
+
// Extra margin-bottom for the above-panel so it stacks clear of the
|
|
79
|
+
// bubbles instead of overlapping them (both anchor at the sprite's top).
|
|
80
|
+
const [panelLift, setPanelLift] = useState(0)
|
|
72
81
|
const [nameDraft, setNameDraft] = useState('')
|
|
82
|
+
// Explicit IME composition tracking: some input methods (WeChat IME on
|
|
83
|
+
// Windows) report keydowns with isComposing === false mid-composition, so
|
|
84
|
+
// the native flag alone is not a safe submit/cancel guard (#303).
|
|
85
|
+
const composingRef = useRef(false)
|
|
73
86
|
const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
|
|
74
87
|
const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
|
|
75
88
|
const hideTimerRef = useRef<number | null>(null)
|
|
@@ -83,6 +96,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
83
96
|
const columns = definition.columns
|
|
84
97
|
const rows = definition.rows
|
|
85
98
|
const tracks = definition.tracks
|
|
99
|
+
const sequences = definition.sequences
|
|
86
100
|
|
|
87
101
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
88
102
|
// frame counts and per-track durations, so nothing else is fetched.
|
|
@@ -106,14 +120,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
106
120
|
// its track's first frame instead of animating (presentation-only; the
|
|
107
121
|
// animation state machine is untouched).
|
|
108
122
|
const spriteScale = display.size / cell.height
|
|
123
|
+
const phase = snapshot?.phase ?? 'idle'
|
|
109
124
|
const animation = snapshot?.animation ?? 'idle'
|
|
110
125
|
const scaleRef = useRef(spriteScale)
|
|
111
126
|
scaleRef.current = spriteScale
|
|
112
127
|
useEffect(() => {
|
|
113
128
|
const reduceMotion = typeof window !== 'undefined'
|
|
114
129
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
115
|
-
const
|
|
116
|
-
const
|
|
130
|
+
const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
|
|
131
|
+
const leadAnimation = sequence?.[0] ?? animation
|
|
132
|
+
const row = rowOfTrack(leadAnimation)
|
|
133
|
+
const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
|
|
117
134
|
// Paint one static sprite frame up front either way, so the pet is never
|
|
118
135
|
// blank while the loop heat-up runs.
|
|
119
136
|
const leadCol = track.frames[0]!
|
|
@@ -124,9 +141,26 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
124
141
|
if (reduceMotion) return
|
|
125
142
|
let raf = 0
|
|
126
143
|
let last = performance.now()
|
|
144
|
+
let sequenceElapsed = 0
|
|
127
145
|
const tick = (ts: number): void => {
|
|
128
146
|
const delta = ts - last
|
|
129
147
|
last = ts
|
|
148
|
+
if (sequence !== undefined) {
|
|
149
|
+
sequenceElapsed += delta
|
|
150
|
+
const current = sequenceFrameAt(sequence, tracks, sequenceElapsed)
|
|
151
|
+
const currentRow = rowOfTrack(current.animation)
|
|
152
|
+
const currentTrack = trimTrack(
|
|
153
|
+
tracks[current.animation],
|
|
154
|
+
rows[currentRow] ?? tracks[current.animation].frames.length,
|
|
155
|
+
)
|
|
156
|
+
const col = currentTrack.frames[current.frameIndex]!
|
|
157
|
+
const pos = framePosition(cell, columns, currentRow, col, scaleRef.current)
|
|
158
|
+
if (spriteRef.current !== null) {
|
|
159
|
+
spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
|
|
160
|
+
}
|
|
161
|
+
raf = requestAnimationFrame(tick)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
130
164
|
// row/track come from the effect scope: they were computed once above
|
|
131
165
|
// and this effect re-runs when animation/tracks/rows change, so the
|
|
132
166
|
// per-frame recompute (trimTrack slices fresh arrays) is pure waste.
|
|
@@ -159,7 +193,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
159
193
|
}
|
|
160
194
|
raf = requestAnimationFrame(tick)
|
|
161
195
|
return () => cancelAnimationFrame(raf)
|
|
162
|
-
}, [animation, cell, columns, rows, tracks])
|
|
196
|
+
}, [animation, phase, cell, columns, rows, tracks, sequences])
|
|
163
197
|
|
|
164
198
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
165
199
|
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
@@ -210,16 +244,46 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
210
244
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
211
245
|
const spriteWidth = Math.round(cell.width * spriteScale)
|
|
212
246
|
const spriteHeight = Math.round(cell.height * spriteScale)
|
|
247
|
+
|
|
213
248
|
// Concurrent sessions each render their own bubble (stacked above the
|
|
214
249
|
// sprite); the legacy single 'bubble' is the fallback when the host serves
|
|
215
|
-
// no per-session list. The hover panel
|
|
216
|
-
// bubbles stay visible and clickable while hovering — no region swap.
|
|
250
|
+
// no per-session list. The hover panel normally sits below the sprite, so
|
|
251
|
+
// the bubbles stay visible and clickable while hovering — no region swap.
|
|
217
252
|
const sessionBubbles = snapshot?.sessions ?? []
|
|
218
253
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
219
254
|
? snapshot?.bubble
|
|
220
255
|
: undefined
|
|
256
|
+
// The display session's inner whisper (碎碎念) — short inner-voice copy
|
|
257
|
+
// woken by the model's output. Interaction feedback takes over the whole
|
|
258
|
+
// bubble area while it plays, so whispers yield to it like status copy.
|
|
259
|
+
const whisper = feedback === null ? snapshot?.whisper : undefined
|
|
260
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
|
|
221
261
|
const displayName = snapshot?.name ?? definition.displayName
|
|
222
262
|
|
|
263
|
+
useLayoutEffect(() => {
|
|
264
|
+
if (!hovered) {
|
|
265
|
+
setPanelAbove(false)
|
|
266
|
+
setPanelLift(0)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const updatePanelPlacement = (): void => {
|
|
270
|
+
const sprite = spriteRef.current
|
|
271
|
+
const panel = panelRef.current
|
|
272
|
+
if (sprite === null || panel === null) return
|
|
273
|
+
const availableBelow = window.innerHeight - sprite.getBoundingClientRect().bottom
|
|
274
|
+
const above = availableBelow < panel.getBoundingClientRect().height + 8
|
|
275
|
+
setPanelAbove(above)
|
|
276
|
+
// The fallback above-placement shares the sprite's top edge with the
|
|
277
|
+
// bubble(s); lift the panel by the bubble area's height so the two
|
|
278
|
+
// never overlap (8px base gap + 6px clearance above the top bubble).
|
|
279
|
+
const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0
|
|
280
|
+
setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0)
|
|
281
|
+
}
|
|
282
|
+
updatePanelPlacement()
|
|
283
|
+
window.addEventListener('resize', updatePanelPlacement)
|
|
284
|
+
return () => window.removeEventListener('resize', updatePanelPlacement)
|
|
285
|
+
}, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, feedback])
|
|
286
|
+
|
|
223
287
|
const float = (
|
|
224
288
|
<div
|
|
225
289
|
ref={floatRef}
|
|
@@ -230,16 +294,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
230
294
|
setHovered(true)
|
|
231
295
|
}}
|
|
232
296
|
onPointerLeave={(e) => {
|
|
233
|
-
// The panel renders OUTSIDE the container's box (absolute,
|
|
297
|
+
// The panel renders OUTSIDE the container's box (absolute, below
|
|
234
298
|
// the sprite), so moving onto it fires pointerleave on the container.
|
|
235
299
|
// Treat a target still inside the container's DOM (the overflowed
|
|
236
300
|
// panel) as "still hovering"; otherwise give the pointer a short
|
|
237
|
-
// grace period to reach the panel across the gap
|
|
301
|
+
// grace period to reach the panel across the gap below the sprite.
|
|
238
302
|
// The bridge ('.panel::after') keeps the pointer inside the hit
|
|
239
303
|
// area, and the grace period covers a slow mouse crossing the
|
|
240
304
|
// remaining sliver.
|
|
241
305
|
const next = e.relatedTarget
|
|
242
306
|
if (next instanceof Node && floatRef.current?.contains(next)) return
|
|
307
|
+
// Never auto-hide while the rename box is open: moving the pointer
|
|
308
|
+
// onto an IME candidate window (an OS-level window outside the
|
|
309
|
+
// webview) fires pointerleave, and unmounting the input mid-IME-
|
|
310
|
+
// composition crashes some input methods / the renderer (#303).
|
|
311
|
+
if (renaming) return
|
|
243
312
|
clearHideTimer()
|
|
244
313
|
hideTimerRef.current = window.setTimeout(() => setHovered(false), 300)
|
|
245
314
|
}}
|
|
@@ -251,7 +320,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
251
320
|
width: spriteWidth,
|
|
252
321
|
height: spriteHeight,
|
|
253
322
|
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
254
|
-
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
|
|
323
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
255
324
|
backgroundRepeat: 'no-repeat',
|
|
256
325
|
backgroundPosition: '0 0',
|
|
257
326
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
@@ -269,12 +338,12 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
269
338
|
aria-label={definition.displayName}
|
|
270
339
|
/>
|
|
271
340
|
{feedback !== null && (
|
|
272
|
-
<div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
341
|
+
<div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
273
342
|
{feedback.text}
|
|
274
343
|
</div>
|
|
275
344
|
)}
|
|
276
|
-
{feedback === null && sessionBubbles.length > 0 && (
|
|
277
|
-
<div className={styles.bubbleStack}>
|
|
345
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
|
|
346
|
+
<div ref={bubbleRef} className={styles.bubbleStack}>
|
|
278
347
|
{sessionBubbles.map(session => (
|
|
279
348
|
<button
|
|
280
349
|
key={session.sessionId}
|
|
@@ -286,16 +355,27 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
286
355
|
{session.bubble}
|
|
287
356
|
</button>
|
|
288
357
|
))}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
358
|
+
{sessionBubbles.length === 0 && statusBubble !== undefined && (
|
|
359
|
+
<div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
|
|
360
|
+
{statusBubble}
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
{whisper !== undefined && (
|
|
364
|
+
// key restarts the entrance animation per fresh whisper.
|
|
365
|
+
<div key={whisper} className={styles.bubbleWhisper}>
|
|
366
|
+
{whisper}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
294
369
|
</div>
|
|
295
370
|
)}
|
|
296
371
|
{hovered && dragRef.current === null && (
|
|
297
372
|
<div
|
|
298
|
-
|
|
373
|
+
ref={panelRef}
|
|
374
|
+
className={clsx(styles.panel, panelAbove && styles.panelAbove)}
|
|
375
|
+
data-placement={panelAbove ? 'above' : 'below'}
|
|
376
|
+
style={panelAbove && panelLift > 0
|
|
377
|
+
? ({ marginBottom: panelLift } as CSSProperties)
|
|
378
|
+
: undefined}
|
|
299
379
|
onPointerEnter={() => {
|
|
300
380
|
// Reaching the panel (or its bridge) must cancel any hide timer
|
|
301
381
|
// the container's pointerleave may have armed while the pointer
|
|
@@ -312,12 +392,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
312
392
|
placeholder={props.t('pet.namePlaceholder')}
|
|
313
393
|
autoFocus
|
|
314
394
|
onChange={(e) => setNameDraft(e.target.value)}
|
|
395
|
+
onCompositionStart={() => { composingRef.current = true }}
|
|
396
|
+
onCompositionEnd={() => { composingRef.current = false }}
|
|
315
397
|
onKeyDown={(e) => {
|
|
316
398
|
// While an IME composition is active (e.g. selecting a
|
|
317
399
|
// Chinese candidate), Enter/Escape keydowns belong to the
|
|
318
400
|
// input method: ignore them so candidate selection can
|
|
319
|
-
// neither submit the draft nor close the rename box.
|
|
320
|
-
|
|
401
|
+
// neither submit the draft nor close the rename box. The
|
|
402
|
+
// explicit ref and the 'Process' key cover IMEs that mark
|
|
403
|
+
// composition keydowns with isComposing === false (#303).
|
|
404
|
+
if (composingRef.current || e.nativeEvent.isComposing || e.key === 'Process') return
|
|
321
405
|
if (e.key === 'Enter') {
|
|
322
406
|
const trimmed = nameDraft.trim()
|
|
323
407
|
if (trimmed !== '') {
|
|
@@ -347,11 +431,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
347
431
|
<>
|
|
348
432
|
<div className={styles.rankRow}>
|
|
349
433
|
<span className={styles.nameCell}>{displayName}</span>
|
|
350
|
-
<span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
434
|
+
<span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
351
435
|
</div>
|
|
352
436
|
<div className={styles.rankRow}>
|
|
353
|
-
<span>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
354
|
-
<span>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
|
|
437
|
+
<span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
438
|
+
<span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
|
|
355
439
|
</div>
|
|
356
440
|
<div className={styles.actions}>
|
|
357
441
|
<button type="button" className={styles.action} onClick={props.onFeed}>
|
|
@@ -361,6 +445,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
361
445
|
type="button"
|
|
362
446
|
className={styles.action}
|
|
363
447
|
onClick={() => {
|
|
448
|
+
// Cancel any pending hide so the rename box cannot
|
|
449
|
+
// unmount right as the user starts typing (#303).
|
|
450
|
+
clearHideTimer()
|
|
364
451
|
setNameDraft(displayName)
|
|
365
452
|
setRenaming(true)
|
|
366
453
|
}}
|
|
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
56
56
|
* already provides the selection.
|
|
57
57
|
*/
|
|
58
58
|
alwaysOpen?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Hide the save/discard footer: cards whose body applies its own changes
|
|
61
|
+
* immediately (an embedded external settings section) have no staged
|
|
62
|
+
* edits, so the footer would sit there permanently disabled.
|
|
63
|
+
*/
|
|
64
|
+
hideFooter?: boolean
|
|
59
65
|
/** The plugin's controls. */
|
|
60
66
|
children: ReactNode
|
|
61
67
|
}
|
|
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
140
146
|
<div className={css.body}>
|
|
141
147
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
142
148
|
{props.children}
|
|
149
|
+
{props.hideFooter === true
|
|
150
|
+
? null
|
|
151
|
+
: (
|
|
143
152
|
<div className={css.footer}>
|
|
144
153
|
{state.failed
|
|
145
154
|
? (
|
|
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
165
174
|
{props.t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
166
175
|
</button>
|
|
167
176
|
</div>
|
|
177
|
+
)}
|
|
168
178
|
</div>
|
|
169
179
|
)
|
|
170
180
|
: null}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
|
|
5
|
+
|
|
6
|
+
describe('pet hover panel css', () => {
|
|
7
|
+
it('anchors the panel below the pet', () => {
|
|
8
|
+
const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
9
|
+
expect(panel).toContain('top: 100%')
|
|
10
|
+
expect(panel).toContain('margin-top: 8px')
|
|
11
|
+
expect(panel).not.toContain('right: 100%')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('extends the hover bridge upward across the panel gap', () => {
|
|
15
|
+
const bridge = css.match(/\.panel::after\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
16
|
+
expect(bridge).toContain('bottom: 100%')
|
|
17
|
+
expect(bridge).toContain('height: 14px')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('styles the whisper bubble as a distinct non-interactive inner voice', () => {
|
|
21
|
+
const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
22
|
+
expect(whisper).toContain('font-style: italic')
|
|
23
|
+
expect(whisper).toContain('pointer-events: none')
|
|
24
|
+
expect(css).toContain('@keyframes pet-whisper-in')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('gives the panel and status bubbles entrance animations', () => {
|
|
28
|
+
expect(css).toContain('@keyframes pet-panel-in')
|
|
29
|
+
expect(css).toContain('@keyframes pet-bubble-in')
|
|
30
|
+
const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
31
|
+
expect(panel).toContain('animation: pet-panel-in')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('can place the panel above and bridge the lower gap', () => {
|
|
35
|
+
const panel = css.match(/\.panelAbove\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
36
|
+
expect(panel).toContain('bottom: 100%')
|
|
37
|
+
expect(panel).toContain('margin-bottom: 8px')
|
|
38
|
+
const bridge = css.match(/\.panelAbove::after\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
39
|
+
expect(bridge).toContain('top: 100%')
|
|
40
|
+
expect(bridge).toContain('bottom: auto')
|
|
41
|
+
})
|
|
42
|
+
})
|