@linxin666/dsh-pet 0.1.19 → 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 +19 -12
- package/README.zh.md +19 -12
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +202 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +897 -42
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
- package/lib/types/affinity.d.ts +22 -18
- 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/PetDockEntry.d.ts +2 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -2
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +2 -2
- package/lib/types/client/PetSprite.d.ts +2 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +100 -25
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +31 -6
- package/lib/types/client/locales.d.ts +82 -80
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -0
- 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/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/spritesheet.d.ts +1 -1
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +3 -12
- 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/index.d.ts +2 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +15 -13
- package/lib/types/invariant.js +2 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +18 -10
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/lib/types/mount-once.js +42 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +6 -4
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +37 -1
- 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/routes.js +2 -2
- package/lib/types/service.d.ts +16 -6
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -8
- 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/PetDockEntry.tsx +3 -0
- package/src/client/PetSprite.test.tsx +246 -4
- package/src/client/PetSprite.tsx +132 -36
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/index.ts +26 -3
- package/src/client/locales.ts +2 -0
- package/src/client/pet-css.test.ts +42 -0
- package/src/client/pet.module.css +173 -22
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/spritesheet.ts +3 -13
- package/src/event-projection.ts +49 -16
- package/src/index.ts +4 -1
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/mount-once.ts +48 -0
- 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 +42 -9
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
|
@@ -37,6 +37,8 @@ export interface PetInjected {
|
|
|
37
37
|
dragEnd: (right: number, bottom: number) => void
|
|
38
38
|
/** Rename the selected pet (persisted by the host). */
|
|
39
39
|
rename: (name: string) => void
|
|
40
|
+
/** Navigate the GUI to the session a bubble reports on. */
|
|
41
|
+
openSession: (sessionId: string) => void
|
|
40
42
|
/** Clear the reaction bubble. */
|
|
41
43
|
feedbackDone: () => void
|
|
42
44
|
}
|
|
@@ -83,6 +85,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
|
|
|
83
85
|
onHide={props.hide}
|
|
84
86
|
onDragEnd={props.dragEnd}
|
|
85
87
|
onRename={props.rename}
|
|
88
|
+
onOpenSession={props.openSession}
|
|
86
89
|
onFeedbackDone={props.feedbackDone}
|
|
87
90
|
t={props.t}
|
|
88
91
|
/>
|
|
@@ -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,11 +87,16 @@ beforeAll(() => {
|
|
|
86
87
|
|
|
87
88
|
afterEach(() => {
|
|
88
89
|
cleanup()
|
|
90
|
+
vi.restoreAllMocks()
|
|
89
91
|
})
|
|
90
92
|
|
|
91
|
-
/** Render the pet with mocked callbacks; returns the rename
|
|
92
|
-
function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
93
|
+
/** Render the pet with mocked callbacks; returns the rename and open spys. */
|
|
94
|
+
function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
95
|
+
onRename: ReturnType<typeof vi.fn>
|
|
96
|
+
onOpenSession: ReturnType<typeof vi.fn>
|
|
97
|
+
} {
|
|
93
98
|
const onRename = vi.fn()
|
|
99
|
+
const onOpenSession = vi.fn()
|
|
94
100
|
const props: PetSpriteProps = {
|
|
95
101
|
snapshot,
|
|
96
102
|
definition: petDefinition(),
|
|
@@ -101,12 +107,13 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnT
|
|
|
101
107
|
onHide: vi.fn(),
|
|
102
108
|
onDragEnd: vi.fn(),
|
|
103
109
|
onRename,
|
|
110
|
+
onOpenSession,
|
|
104
111
|
onFeedbackDone: vi.fn(),
|
|
105
112
|
t,
|
|
106
113
|
...overrides,
|
|
107
114
|
}
|
|
108
115
|
render(<PetSprite {...props} />)
|
|
109
|
-
return { onRename }
|
|
116
|
+
return { onRename, onOpenSession }
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
/** Hover the sprite to open the panel, then click the rename button. */
|
|
@@ -171,6 +178,131 @@ describe('PetSprite rename input', () => {
|
|
|
171
178
|
expect(onRename).not.toHaveBeenCalled()
|
|
172
179
|
expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
|
|
173
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
|
+
})
|
|
174
306
|
})
|
|
175
307
|
|
|
176
308
|
describe('PetSprite status bubble', () => {
|
|
@@ -212,6 +344,36 @@ describe('PetSprite status bubble', () => {
|
|
|
212
344
|
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
213
345
|
})
|
|
214
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
|
+
|
|
215
377
|
it('lets feedback replace the whole session bubble stack', () => {
|
|
216
378
|
renderPet({
|
|
217
379
|
snapshot: {
|
|
@@ -227,6 +389,54 @@ describe('PetSprite status bubble', () => {
|
|
|
227
389
|
expect(screen.queryByText('正在思考')).toBeNull()
|
|
228
390
|
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
229
391
|
})
|
|
392
|
+
|
|
393
|
+
it('clicking a session bubble navigates to that session', () => {
|
|
394
|
+
const { onOpenSession } = renderPet({
|
|
395
|
+
snapshot: {
|
|
396
|
+
...workingSnapshot,
|
|
397
|
+
bubble: '正在思考',
|
|
398
|
+
sessions: [
|
|
399
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
400
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
401
|
+
],
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
fireEvent.click(screen.getByText('正在使用 grep'))
|
|
405
|
+
expect(onOpenSession).toHaveBeenCalledTimes(1)
|
|
406
|
+
expect(onOpenSession).toHaveBeenCalledWith('s-b')
|
|
407
|
+
fireEvent.click(screen.getByText('正在思考'))
|
|
408
|
+
expect(onOpenSession).toHaveBeenCalledTimes(2)
|
|
409
|
+
expect(onOpenSession).toHaveBeenCalledWith('s-a')
|
|
410
|
+
// Petting stays on the sprite only: bubble clicks must not pet.
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('clicking the legacy single bubble does not navigate (no session identity)', () => {
|
|
414
|
+
const { onOpenSession } = renderPet({ snapshot: workingSnapshot })
|
|
415
|
+
fireEvent.click(screen.getByText('正在思考'))
|
|
416
|
+
expect(onOpenSession).not.toHaveBeenCalled()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('keeps session bubbles visible and clickable while the hover panel is open', () => {
|
|
420
|
+
// Regression: the panel used to occupy the same region as the bubble
|
|
421
|
+
// stack and hide it on hover, so reaching a bubble was impossible. The
|
|
422
|
+
// panel now opens beside the sprite and the stack stays interactive.
|
|
423
|
+
const { onOpenSession } = renderPet({
|
|
424
|
+
snapshot: {
|
|
425
|
+
...workingSnapshot,
|
|
426
|
+
sessions: [
|
|
427
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
428
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
433
|
+
// The hover panel is open...
|
|
434
|
+
expect(screen.queryByText('改名')).not.toBeNull()
|
|
435
|
+
// ...and the bubbles are still there, still clickable.
|
|
436
|
+
expect(screen.getByText('正在使用 grep')).not.toBeNull()
|
|
437
|
+
fireEvent.click(screen.getByText('正在使用 grep'))
|
|
438
|
+
expect(onOpenSession).toHaveBeenCalledWith('s-b')
|
|
439
|
+
})
|
|
230
440
|
})
|
|
231
441
|
|
|
232
442
|
describe('PetSprite definition-driven render', () => {
|
|
@@ -240,4 +450,36 @@ describe('PetSprite definition-driven render', () => {
|
|
|
240
450
|
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
241
451
|
expect(screen.queryByText('泡泡')).not.toBeNull()
|
|
242
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
|
+
})
|
|
243
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
|
|
|
@@ -43,6 +44,8 @@ export interface PetSpriteProps {
|
|
|
43
44
|
onDragEnd: (right: number, bottom: number) => void
|
|
44
45
|
/** Rename the selected pet (persisted by the host). */
|
|
45
46
|
onRename: (name: string) => void
|
|
47
|
+
/** Navigate to the session one status bubble reports on. */
|
|
48
|
+
onOpenSession: (sessionId: string) => void
|
|
46
49
|
/** Clear the reaction bubble (after its CSS animation). */
|
|
47
50
|
onFeedbackDone: () => void
|
|
48
51
|
/** Locale translate seat (namespace-bound). */
|
|
@@ -64,10 +67,22 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
64
67
|
const { snapshot, definition, display, feedback } = props
|
|
65
68
|
const spriteRef = useRef<HTMLDivElement | null>(null)
|
|
66
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)
|
|
67
74
|
const [imageReady, setImageReady] = useState(false)
|
|
68
75
|
const [hovered, setHovered] = useState(false)
|
|
69
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)
|
|
70
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)
|
|
71
86
|
const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
|
|
72
87
|
const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
|
|
73
88
|
const hideTimerRef = useRef<number | null>(null)
|
|
@@ -81,6 +96,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
81
96
|
const columns = definition.columns
|
|
82
97
|
const rows = definition.rows
|
|
83
98
|
const tracks = definition.tracks
|
|
99
|
+
const sequences = definition.sequences
|
|
84
100
|
|
|
85
101
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
86
102
|
// frame counts and per-track durations, so nothing else is fetched.
|
|
@@ -104,14 +120,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
104
120
|
// its track's first frame instead of animating (presentation-only; the
|
|
105
121
|
// animation state machine is untouched).
|
|
106
122
|
const spriteScale = display.size / cell.height
|
|
123
|
+
const phase = snapshot?.phase ?? 'idle'
|
|
107
124
|
const animation = snapshot?.animation ?? 'idle'
|
|
108
125
|
const scaleRef = useRef(spriteScale)
|
|
109
126
|
scaleRef.current = spriteScale
|
|
110
127
|
useEffect(() => {
|
|
111
128
|
const reduceMotion = typeof window !== 'undefined'
|
|
112
129
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
113
|
-
const
|
|
114
|
-
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)
|
|
115
134
|
// Paint one static sprite frame up front either way, so the pet is never
|
|
116
135
|
// blank while the loop heat-up runs.
|
|
117
136
|
const leadCol = track.frames[0]!
|
|
@@ -122,13 +141,29 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
122
141
|
if (reduceMotion) return
|
|
123
142
|
let raf = 0
|
|
124
143
|
let last = performance.now()
|
|
144
|
+
let sequenceElapsed = 0
|
|
125
145
|
const tick = (ts: number): void => {
|
|
126
146
|
const delta = ts - last
|
|
127
147
|
last = ts
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
164
|
+
// row/track come from the effect scope: they were computed once above
|
|
165
|
+
// and this effect re-runs when animation/tracks/rows change, so the
|
|
166
|
+
// per-frame recompute (trimTrack slices fresh arrays) is pure waste.
|
|
132
167
|
const st = frameRef.current
|
|
133
168
|
if (st.track !== animation) {
|
|
134
169
|
st.track = animation
|
|
@@ -158,7 +193,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
158
193
|
}
|
|
159
194
|
raf = requestAnimationFrame(tick)
|
|
160
195
|
return () => cancelAnimationFrame(raf)
|
|
161
|
-
}, [animation, cell, columns, rows, tracks])
|
|
196
|
+
}, [animation, phase, cell, columns, rows, tracks, sequences])
|
|
162
197
|
|
|
163
198
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
164
199
|
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
@@ -209,15 +244,46 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
209
244
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
210
245
|
const spriteWidth = Math.round(cell.width * spriteScale)
|
|
211
246
|
const spriteHeight = Math.round(cell.height * spriteScale)
|
|
247
|
+
|
|
212
248
|
// Concurrent sessions each render their own bubble (stacked above the
|
|
213
249
|
// sprite); the legacy single 'bubble' is the fallback when the host serves
|
|
214
|
-
// no per-session list.
|
|
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.
|
|
215
252
|
const sessionBubbles = snapshot?.sessions ?? []
|
|
216
|
-
const statusBubble = feedback === null &&
|
|
253
|
+
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
217
254
|
? snapshot?.bubble
|
|
218
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
|
|
219
261
|
const displayName = snapshot?.name ?? definition.displayName
|
|
220
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
|
+
|
|
221
287
|
const float = (
|
|
222
288
|
<div
|
|
223
289
|
ref={floatRef}
|
|
@@ -228,15 +294,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
228
294
|
setHovered(true)
|
|
229
295
|
}}
|
|
230
296
|
onPointerLeave={(e) => {
|
|
231
|
-
// The panel
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// bridge ('.panel::after') keeps the pointer inside the hit
|
|
237
|
-
// the grace period covers a slow mouse crossing the
|
|
297
|
+
// The panel renders OUTSIDE the container's box (absolute, below
|
|
298
|
+
// the sprite), so moving onto it fires pointerleave on the container.
|
|
299
|
+
// Treat a target still inside the container's DOM (the overflowed
|
|
300
|
+
// panel) as "still hovering"; otherwise give the pointer a short
|
|
301
|
+
// grace period to reach the panel across the gap below the sprite.
|
|
302
|
+
// The bridge ('.panel::after') keeps the pointer inside the hit
|
|
303
|
+
// area, and the grace period covers a slow mouse crossing the
|
|
304
|
+
// remaining sliver.
|
|
238
305
|
const next = e.relatedTarget
|
|
239
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
|
|
240
312
|
clearHideTimer()
|
|
241
313
|
hideTimerRef.current = window.setTimeout(() => setHovered(false), 300)
|
|
242
314
|
}}
|
|
@@ -248,7 +320,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
248
320
|
width: spriteWidth,
|
|
249
321
|
height: spriteHeight,
|
|
250
322
|
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
251
|
-
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',
|
|
252
324
|
backgroundRepeat: 'no-repeat',
|
|
253
325
|
backgroundPosition: '0 0',
|
|
254
326
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
@@ -266,27 +338,44 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
266
338
|
aria-label={definition.displayName}
|
|
267
339
|
/>
|
|
268
340
|
{feedback !== null && (
|
|
269
|
-
<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)}>
|
|
270
342
|
{feedback.text}
|
|
271
343
|
</div>
|
|
272
344
|
)}
|
|
273
|
-
{feedback === null &&
|
|
274
|
-
<div className={styles.bubbleStack}
|
|
345
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
|
|
346
|
+
<div ref={bubbleRef} className={styles.bubbleStack}>
|
|
275
347
|
{sessionBubbles.map(session => (
|
|
276
|
-
<
|
|
348
|
+
<button
|
|
349
|
+
key={session.sessionId}
|
|
350
|
+
type="button"
|
|
351
|
+
className={clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable)}
|
|
352
|
+
title={props.t('pet.openSessionHint')}
|
|
353
|
+
onClick={() => { props.onOpenSession(session.sessionId) }}
|
|
354
|
+
>
|
|
277
355
|
{session.bubble}
|
|
278
|
-
</
|
|
356
|
+
</button>
|
|
279
357
|
))}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
)}
|
|
285
369
|
</div>
|
|
286
370
|
)}
|
|
287
371
|
{hovered && dragRef.current === null && (
|
|
288
372
|
<div
|
|
289
|
-
|
|
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}
|
|
290
379
|
onPointerEnter={() => {
|
|
291
380
|
// Reaching the panel (or its bridge) must cancel any hide timer
|
|
292
381
|
// the container's pointerleave may have armed while the pointer
|
|
@@ -303,12 +392,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
303
392
|
placeholder={props.t('pet.namePlaceholder')}
|
|
304
393
|
autoFocus
|
|
305
394
|
onChange={(e) => setNameDraft(e.target.value)}
|
|
395
|
+
onCompositionStart={() => { composingRef.current = true }}
|
|
396
|
+
onCompositionEnd={() => { composingRef.current = false }}
|
|
306
397
|
onKeyDown={(e) => {
|
|
307
398
|
// While an IME composition is active (e.g. selecting a
|
|
308
399
|
// Chinese candidate), Enter/Escape keydowns belong to the
|
|
309
400
|
// input method: ignore them so candidate selection can
|
|
310
|
-
// neither submit the draft nor close the rename box.
|
|
311
|
-
|
|
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
|
|
312
405
|
if (e.key === 'Enter') {
|
|
313
406
|
const trimmed = nameDraft.trim()
|
|
314
407
|
if (trimmed !== '') {
|
|
@@ -338,11 +431,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
338
431
|
<>
|
|
339
432
|
<div className={styles.rankRow}>
|
|
340
433
|
<span className={styles.nameCell}>{displayName}</span>
|
|
341
|
-
<span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
434
|
+
<span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
342
435
|
</div>
|
|
343
436
|
<div className={styles.rankRow}>
|
|
344
|
-
<span>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
345
|
-
<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>
|
|
346
439
|
</div>
|
|
347
440
|
<div className={styles.actions}>
|
|
348
441
|
<button type="button" className={styles.action} onClick={props.onFeed}>
|
|
@@ -352,6 +445,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
352
445
|
type="button"
|
|
353
446
|
className={styles.action}
|
|
354
447
|
onClick={() => {
|
|
448
|
+
// Cancel any pending hide so the rename box cannot
|
|
449
|
+
// unmount right as the user starts typing (#303).
|
|
450
|
+
clearHideTimer()
|
|
355
451
|
setNameDraft(displayName)
|
|
356
452
|
setRenaming(true)
|
|
357
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}
|