@linxin666/dsh-pet 0.1.20 → 0.2.1
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 +18 -11
- package/README.zh.md +18 -11
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +394 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +1007 -43
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
- 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 +756 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +133 -16
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -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/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/loopback.d.ts +25 -0
- package/lib/types/loopback.d.ts.map +1 -0
- package/lib/types/loopback.js +68 -0
- 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 +47 -3
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +35 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -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 +787 -0
- package/src/client/PetDockEntry.test.tsx +93 -0
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSprite.test.tsx +253 -3
- package/src/client/PetSprite.tsx +192 -37
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/index.test.tsx +95 -0
- package/src/client/index.ts +1 -0
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +54 -0
- package/src/client/pet.module.css +215 -57
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/loopback.ts +63 -0
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +79 -4
- package/src/registry.ts +58 -6
- package/src/remarks.ts +36 -0
- package/src/routes.ts +11 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
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,29 @@ 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)
|
|
76
|
+
// Multi-session bubble stack: collapsed by default (only the display
|
|
77
|
+
// session's bubble + a '+N' badge), expanded on stack hover (peek) or by
|
|
78
|
+
// tapping the badge (pinned, for touch). The display session's bubble
|
|
79
|
+
// anchors the bottom of the stack and never moves when extras open above
|
|
80
|
+
// it, so the pointer target cannot flicker.
|
|
81
|
+
const [stackPeek, setStackPeek] = useState(false)
|
|
82
|
+
const [stackPinned, setStackPinned] = useState(false)
|
|
71
83
|
const [renaming, setRenaming] = useState(false)
|
|
84
|
+
const [panelAbove, setPanelAbove] = useState(false)
|
|
85
|
+
// Extra margin-bottom for the above-panel so it stacks clear of the
|
|
86
|
+
// bubbles instead of overlapping them (both anchor at the sprite's top).
|
|
87
|
+
const [panelLift, setPanelLift] = useState(0)
|
|
72
88
|
const [nameDraft, setNameDraft] = useState('')
|
|
89
|
+
// Explicit IME composition tracking: some input methods (WeChat IME on
|
|
90
|
+
// Windows) report keydowns with isComposing === false mid-composition, so
|
|
91
|
+
// the native flag alone is not a safe submit/cancel guard (#303).
|
|
92
|
+
const composingRef = useRef(false)
|
|
73
93
|
const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
|
|
74
94
|
const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
|
|
75
95
|
const hideTimerRef = useRef<number | null>(null)
|
|
@@ -83,6 +103,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
83
103
|
const columns = definition.columns
|
|
84
104
|
const rows = definition.rows
|
|
85
105
|
const tracks = definition.tracks
|
|
106
|
+
const sequences = definition.sequences
|
|
86
107
|
|
|
87
108
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
88
109
|
// frame counts and per-track durations, so nothing else is fetched.
|
|
@@ -106,14 +127,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
106
127
|
// its track's first frame instead of animating (presentation-only; the
|
|
107
128
|
// animation state machine is untouched).
|
|
108
129
|
const spriteScale = display.size / cell.height
|
|
130
|
+
const phase = snapshot?.phase ?? 'idle'
|
|
109
131
|
const animation = snapshot?.animation ?? 'idle'
|
|
110
132
|
const scaleRef = useRef(spriteScale)
|
|
111
133
|
scaleRef.current = spriteScale
|
|
112
134
|
useEffect(() => {
|
|
113
135
|
const reduceMotion = typeof window !== 'undefined'
|
|
114
136
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
115
|
-
const
|
|
116
|
-
const
|
|
137
|
+
const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
|
|
138
|
+
const leadAnimation = sequence?.[0] ?? animation
|
|
139
|
+
const row = rowOfTrack(leadAnimation)
|
|
140
|
+
const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
|
|
117
141
|
// Paint one static sprite frame up front either way, so the pet is never
|
|
118
142
|
// blank while the loop heat-up runs.
|
|
119
143
|
const leadCol = track.frames[0]!
|
|
@@ -124,9 +148,26 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
124
148
|
if (reduceMotion) return
|
|
125
149
|
let raf = 0
|
|
126
150
|
let last = performance.now()
|
|
151
|
+
let sequenceElapsed = 0
|
|
127
152
|
const tick = (ts: number): void => {
|
|
128
153
|
const delta = ts - last
|
|
129
154
|
last = ts
|
|
155
|
+
if (sequence !== undefined) {
|
|
156
|
+
sequenceElapsed += delta
|
|
157
|
+
const current = sequenceFrameAt(sequence, tracks, sequenceElapsed)
|
|
158
|
+
const currentRow = rowOfTrack(current.animation)
|
|
159
|
+
const currentTrack = trimTrack(
|
|
160
|
+
tracks[current.animation],
|
|
161
|
+
rows[currentRow] ?? tracks[current.animation].frames.length,
|
|
162
|
+
)
|
|
163
|
+
const col = currentTrack.frames[current.frameIndex]!
|
|
164
|
+
const pos = framePosition(cell, columns, currentRow, col, scaleRef.current)
|
|
165
|
+
if (spriteRef.current !== null) {
|
|
166
|
+
spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
|
|
167
|
+
}
|
|
168
|
+
raf = requestAnimationFrame(tick)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
130
171
|
// row/track come from the effect scope: they were computed once above
|
|
131
172
|
// and this effect re-runs when animation/tracks/rows change, so the
|
|
132
173
|
// per-frame recompute (trimTrack slices fresh arrays) is pure waste.
|
|
@@ -159,7 +200,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
159
200
|
}
|
|
160
201
|
raf = requestAnimationFrame(tick)
|
|
161
202
|
return () => cancelAnimationFrame(raf)
|
|
162
|
-
}, [animation, cell, columns, rows, tracks])
|
|
203
|
+
}, [animation, phase, cell, columns, rows, tracks, sequences])
|
|
163
204
|
|
|
164
205
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
165
206
|
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
@@ -183,6 +224,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
183
224
|
}
|
|
184
225
|
}
|
|
185
226
|
|
|
227
|
+
// Clear any pending auto-hide timer on unmount: a stray callback after
|
|
228
|
+
// teardown reads window through react-dom and failed CI runs with
|
|
229
|
+
// "window is not defined" (slow-runner timing, PetSprite.test.tsx).
|
|
230
|
+
useEffect(() => () => clearHideTimer(), [])
|
|
231
|
+
|
|
186
232
|
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
187
233
|
e.preventDefault()
|
|
188
234
|
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
@@ -210,16 +256,58 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
210
256
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
211
257
|
const spriteWidth = Math.round(cell.width * spriteScale)
|
|
212
258
|
const spriteHeight = Math.round(cell.height * spriteScale)
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
259
|
+
|
|
260
|
+
// Concurrent sessions share one bubble slot: only the display session
|
|
261
|
+
// speaks by default, and the rest hide behind a '+N' badge until the stack
|
|
262
|
+
// is hovered/pinned open. The legacy single 'bubble' is the fallback when
|
|
263
|
+
// the host serves no per-session list. The hover panel normally sits below
|
|
264
|
+
// the sprite, so the bubbles stay visible and clickable — no region swap.
|
|
217
265
|
const sessionBubbles = snapshot?.sessions ?? []
|
|
266
|
+
const stackOpen = stackPeek || stackPinned
|
|
267
|
+
const collapsed = !stackOpen && sessionBubbles.length > 1
|
|
268
|
+
const visibleSessions = collapsed ? sessionBubbles.slice(0, 1) : sessionBubbles
|
|
218
269
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
219
270
|
? snapshot?.bubble
|
|
220
271
|
: undefined
|
|
272
|
+
// The display session's inner whisper (碎碎念) — short inner-voice copy
|
|
273
|
+
// woken by the model's output. Instead of a second bubble of its own, a
|
|
274
|
+
// fresh whisper takes over the display session's bubble (the stack top, or
|
|
275
|
+
// the single status bubble) and re-tints it, so the pet never wears two
|
|
276
|
+
// voices at once. Interaction feedback takes over the whole bubble area
|
|
277
|
+
// while it plays, so whispers yield to it like status copy.
|
|
278
|
+
const whisper = feedback === null ? snapshot?.whisper : undefined
|
|
279
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
|
|
221
280
|
const displayName = snapshot?.name ?? definition.displayName
|
|
222
281
|
|
|
282
|
+
// A settled session list can no longer stay pinned open.
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (sessionBubbles.length <= 1) setStackPinned(false)
|
|
285
|
+
}, [sessionBubbles.length])
|
|
286
|
+
|
|
287
|
+
useLayoutEffect(() => {
|
|
288
|
+
if (!hovered) {
|
|
289
|
+
setPanelAbove(false)
|
|
290
|
+
setPanelLift(0)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
const updatePanelPlacement = (): void => {
|
|
294
|
+
const sprite = spriteRef.current
|
|
295
|
+
const panel = panelRef.current
|
|
296
|
+
if (sprite === null || panel === null) return
|
|
297
|
+
const availableBelow = window.innerHeight - sprite.getBoundingClientRect().bottom
|
|
298
|
+
const above = availableBelow < panel.getBoundingClientRect().height + 8
|
|
299
|
+
setPanelAbove(above)
|
|
300
|
+
// The fallback above-placement shares the sprite's top edge with the
|
|
301
|
+
// bubble(s); lift the panel by the bubble area's height so the two
|
|
302
|
+
// never overlap (8px base gap + 6px clearance above the top bubble).
|
|
303
|
+
const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0
|
|
304
|
+
setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0)
|
|
305
|
+
}
|
|
306
|
+
updatePanelPlacement()
|
|
307
|
+
window.addEventListener('resize', updatePanelPlacement)
|
|
308
|
+
return () => window.removeEventListener('resize', updatePanelPlacement)
|
|
309
|
+
}, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, stackOpen, feedback])
|
|
310
|
+
|
|
223
311
|
const float = (
|
|
224
312
|
<div
|
|
225
313
|
ref={floatRef}
|
|
@@ -230,16 +318,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
230
318
|
setHovered(true)
|
|
231
319
|
}}
|
|
232
320
|
onPointerLeave={(e) => {
|
|
233
|
-
// The panel renders OUTSIDE the container's box (absolute,
|
|
321
|
+
// The panel renders OUTSIDE the container's box (absolute, below
|
|
234
322
|
// the sprite), so moving onto it fires pointerleave on the container.
|
|
235
323
|
// Treat a target still inside the container's DOM (the overflowed
|
|
236
324
|
// panel) as "still hovering"; otherwise give the pointer a short
|
|
237
|
-
// grace period to reach the panel across the gap
|
|
325
|
+
// grace period to reach the panel across the gap below the sprite.
|
|
238
326
|
// The bridge ('.panel::after') keeps the pointer inside the hit
|
|
239
327
|
// area, and the grace period covers a slow mouse crossing the
|
|
240
328
|
// remaining sliver.
|
|
241
329
|
const next = e.relatedTarget
|
|
242
330
|
if (next instanceof Node && floatRef.current?.contains(next)) return
|
|
331
|
+
// Never auto-hide while the rename box is open: moving the pointer
|
|
332
|
+
// onto an IME candidate window (an OS-level window outside the
|
|
333
|
+
// webview) fires pointerleave, and unmounting the input mid-IME-
|
|
334
|
+
// composition crashes some input methods / the renderer (#303).
|
|
335
|
+
if (renaming) return
|
|
243
336
|
clearHideTimer()
|
|
244
337
|
hideTimerRef.current = window.setTimeout(() => setHovered(false), 300)
|
|
245
338
|
}}
|
|
@@ -251,7 +344,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
251
344
|
width: spriteWidth,
|
|
252
345
|
height: spriteHeight,
|
|
253
346
|
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
254
|
-
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
|
|
347
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
255
348
|
backgroundRepeat: 'no-repeat',
|
|
256
349
|
backgroundPosition: '0 0',
|
|
257
350
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
@@ -269,33 +362,88 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
269
362
|
aria-label={definition.displayName}
|
|
270
363
|
/>
|
|
271
364
|
{feedback !== null && (
|
|
272
|
-
<div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
365
|
+
<div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
273
366
|
{feedback.text}
|
|
274
367
|
</div>
|
|
275
368
|
)}
|
|
276
|
-
{feedback === null && sessionBubbles.length > 0 && (
|
|
277
|
-
<div
|
|
278
|
-
{
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
369
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
|
|
370
|
+
<div
|
|
371
|
+
ref={bubbleRef}
|
|
372
|
+
className={styles.bubbleStack}
|
|
373
|
+
onPointerEnter={() => setStackPeek(true)}
|
|
374
|
+
onPointerLeave={() => setStackPeek(false)}
|
|
375
|
+
>
|
|
376
|
+
{visibleSessions.map((session, index) => {
|
|
377
|
+
// The whisper rides the display session's bubble — the stack's
|
|
378
|
+
// primary entry (DOM-first, rendered bottom-most by the reversed
|
|
379
|
+
// column so it stays glued to the sprite when extras open above).
|
|
380
|
+
// The key swap restarts the entrance animation so the mood change
|
|
381
|
+
// reads as the bubble re-speaking.
|
|
382
|
+
const speaksWhisper = index === 0 && whisper !== undefined
|
|
383
|
+
const bubble = (
|
|
384
|
+
<button
|
|
385
|
+
key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
|
|
386
|
+
type="button"
|
|
387
|
+
className={clsx(
|
|
388
|
+
styles.bubble,
|
|
389
|
+
styles.bubbleStatus,
|
|
390
|
+
styles.bubbleClickable,
|
|
391
|
+
speaksWhisper && styles.bubbleWhisper,
|
|
392
|
+
)}
|
|
393
|
+
title={props.t('pet.openSessionHint')}
|
|
394
|
+
onClick={() => { props.onOpenSession(session.sessionId) }}
|
|
395
|
+
>
|
|
396
|
+
{speaksWhisper ? whisper : session.bubble}
|
|
397
|
+
</button>
|
|
398
|
+
)
|
|
399
|
+
// The primary bubble carries the '+N' badge while other sessions
|
|
400
|
+
// hide behind it; the badge toggles the pinned (touch) expansion.
|
|
401
|
+
if (index !== 0 || sessionBubbles.length <= 1) return bubble
|
|
402
|
+
return (
|
|
403
|
+
<span key="primary" className={styles.bubbleAnchor}>
|
|
404
|
+
{bubble}
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
className={styles.bubbleMore}
|
|
408
|
+
title={stackOpen
|
|
409
|
+
? props.t('pet.collapseSessions')
|
|
410
|
+
: props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
|
|
411
|
+
aria-label={stackOpen
|
|
412
|
+
? props.t('pet.collapseSessions')
|
|
413
|
+
: props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
|
|
414
|
+
aria-expanded={stackOpen}
|
|
415
|
+
onClick={(e) => {
|
|
416
|
+
e.stopPropagation()
|
|
417
|
+
setStackPinned(open => !open)
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{stackOpen ? '×' : '+' + String(sessionBubbles.length - 1)}
|
|
421
|
+
</button>
|
|
422
|
+
</span>
|
|
423
|
+
)
|
|
424
|
+
})}
|
|
425
|
+
{sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
|
|
426
|
+
// The key swap (status copy <-> whisper) restarts the entrance
|
|
427
|
+
// animation on every mood change.
|
|
428
|
+
<div
|
|
429
|
+
key={whisper === undefined ? 'status' : 'whisper:' + whisper}
|
|
430
|
+
className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
|
|
431
|
+
role="status"
|
|
432
|
+
aria-live="polite"
|
|
285
433
|
>
|
|
286
|
-
{
|
|
287
|
-
</
|
|
288
|
-
)
|
|
289
|
-
</div>
|
|
290
|
-
)}
|
|
291
|
-
{statusBubble !== undefined && (
|
|
292
|
-
<div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
|
|
293
|
-
{statusBubble}
|
|
434
|
+
{whisper ?? statusBubble}
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
294
437
|
</div>
|
|
295
438
|
)}
|
|
296
439
|
{hovered && dragRef.current === null && (
|
|
297
440
|
<div
|
|
298
|
-
|
|
441
|
+
ref={panelRef}
|
|
442
|
+
className={clsx(styles.panel, panelAbove && styles.panelAbove)}
|
|
443
|
+
data-placement={panelAbove ? 'above' : 'below'}
|
|
444
|
+
style={panelAbove && panelLift > 0
|
|
445
|
+
? ({ marginBottom: panelLift } as CSSProperties)
|
|
446
|
+
: undefined}
|
|
299
447
|
onPointerEnter={() => {
|
|
300
448
|
// Reaching the panel (or its bridge) must cancel any hide timer
|
|
301
449
|
// the container's pointerleave may have armed while the pointer
|
|
@@ -312,12 +460,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
312
460
|
placeholder={props.t('pet.namePlaceholder')}
|
|
313
461
|
autoFocus
|
|
314
462
|
onChange={(e) => setNameDraft(e.target.value)}
|
|
463
|
+
onCompositionStart={() => { composingRef.current = true }}
|
|
464
|
+
onCompositionEnd={() => { composingRef.current = false }}
|
|
315
465
|
onKeyDown={(e) => {
|
|
316
466
|
// While an IME composition is active (e.g. selecting a
|
|
317
467
|
// Chinese candidate), Enter/Escape keydowns belong to the
|
|
318
468
|
// input method: ignore them so candidate selection can
|
|
319
|
-
// neither submit the draft nor close the rename box.
|
|
320
|
-
|
|
469
|
+
// neither submit the draft nor close the rename box. The
|
|
470
|
+
// explicit ref and the 'Process' key cover IMEs that mark
|
|
471
|
+
// composition keydowns with isComposing === false (#303).
|
|
472
|
+
if (composingRef.current || e.nativeEvent.isComposing || e.key === 'Process') return
|
|
321
473
|
if (e.key === 'Enter') {
|
|
322
474
|
const trimmed = nameDraft.trim()
|
|
323
475
|
if (trimmed !== '') {
|
|
@@ -347,11 +499,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
347
499
|
<>
|
|
348
500
|
<div className={styles.rankRow}>
|
|
349
501
|
<span className={styles.nameCell}>{displayName}</span>
|
|
350
|
-
<span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
502
|
+
<span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
351
503
|
</div>
|
|
352
504
|
<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>
|
|
505
|
+
<span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
506
|
+
<span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
|
|
355
507
|
</div>
|
|
356
508
|
<div className={styles.actions}>
|
|
357
509
|
<button type="button" className={styles.action} onClick={props.onFeed}>
|
|
@@ -361,6 +513,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
361
513
|
type="button"
|
|
362
514
|
className={styles.action}
|
|
363
515
|
onClick={() => {
|
|
516
|
+
// Cancel any pending hide so the rename box cannot
|
|
517
|
+
// unmount right as the user starts typing (#303).
|
|
518
|
+
clearHideTimer()
|
|
364
519
|
setNameDraft(displayName)
|
|
365
520
|
setRenaming(true)
|
|
366
521
|
}}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ui-plugin-config PluginCard in a self-contained slice.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
12
12
|
import type { CardShell } from './settings-form.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
@@ -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}
|
|
@@ -245,6 +255,210 @@ export function ValueField(props: FieldProps & {
|
|
|
245
255
|
)
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
|
|
259
|
+
// 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
|
|
260
|
+
function isSkinActive(): boolean {
|
|
261
|
+
const datasetList = Object.keys(document.body.dataset)
|
|
262
|
+
const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
|
|
263
|
+
return isActive
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface SelectOption {
|
|
267
|
+
value: string
|
|
268
|
+
label: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SELECT_CLOSE_MS = 100
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
275
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
276
|
+
* selectors keep working; under the default appearance it renders a
|
|
277
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
278
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
279
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
280
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
281
|
+
*/
|
|
282
|
+
export function SelectField(props: {
|
|
283
|
+
id: string
|
|
284
|
+
options: ReadonlyArray<SelectOption>
|
|
285
|
+
value: string
|
|
286
|
+
disabled: boolean
|
|
287
|
+
invalid: boolean
|
|
288
|
+
onEdit: (text: string) => void
|
|
289
|
+
}) {
|
|
290
|
+
const { id, options, value } = props
|
|
291
|
+
const [open, setOpen] = useState(false)
|
|
292
|
+
const [closing, setClosing] = useState(false)
|
|
293
|
+
const [phase, setPhase] = useState<'initial' | 'open'>('initial')
|
|
294
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
295
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
296
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
297
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
298
|
+
|
|
299
|
+
const currentIndex = () => {
|
|
300
|
+
const index = options.findIndex(option => option.value === value)
|
|
301
|
+
return index >= 0 ? index : 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const close = useCallback(() => {
|
|
305
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
306
|
+
setClosing(true)
|
|
307
|
+
closeTimer.current = setTimeout(() => {
|
|
308
|
+
setClosing(false)
|
|
309
|
+
setOpen(false)
|
|
310
|
+
}, SELECT_CLOSE_MS)
|
|
311
|
+
}, [])
|
|
312
|
+
|
|
313
|
+
const openPopup = () => {
|
|
314
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
315
|
+
setActiveIndex(currentIndex())
|
|
316
|
+
setPhase('initial')
|
|
317
|
+
setClosing(false)
|
|
318
|
+
setOpen(true)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const commit = (index: number) => {
|
|
322
|
+
const option = options[index]
|
|
323
|
+
if (option) props.onEdit(option.value)
|
|
324
|
+
close()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const onTriggerClick = () => {
|
|
328
|
+
if (props.disabled) return
|
|
329
|
+
if (open && !closing) close()
|
|
330
|
+
else openPopup()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
334
|
+
if (props.disabled) return
|
|
335
|
+
const count = options.length
|
|
336
|
+
switch (event.key) {
|
|
337
|
+
case 'ArrowDown':
|
|
338
|
+
case 'ArrowUp':
|
|
339
|
+
case 'Enter':
|
|
340
|
+
case ' ':
|
|
341
|
+
event.preventDefault()
|
|
342
|
+
if (!open) {
|
|
343
|
+
openPopup()
|
|
344
|
+
} else if (!closing) {
|
|
345
|
+
if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
|
|
346
|
+
else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
|
|
347
|
+
else commit(activeIndex)
|
|
348
|
+
}
|
|
349
|
+
break
|
|
350
|
+
case 'Escape':
|
|
351
|
+
if (open) {
|
|
352
|
+
event.preventDefault()
|
|
353
|
+
event.stopPropagation()
|
|
354
|
+
close()
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
case 'Tab':
|
|
358
|
+
if (open) close()
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
useEffect(() => () => {
|
|
364
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
365
|
+
}, [])
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
if (open && !closing && phase === 'initial') {
|
|
368
|
+
void popupRef.current?.offsetHeight
|
|
369
|
+
setPhase('open')
|
|
370
|
+
}
|
|
371
|
+
}, [open, closing, phase])
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!open) return
|
|
375
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
376
|
+
const target = event.target
|
|
377
|
+
if (target instanceof Node && !wrapRef.current?.contains(target)) close()
|
|
378
|
+
}
|
|
379
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
380
|
+
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
381
|
+
}, [open, close])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (props.disabled && open) close()
|
|
385
|
+
}, [props.disabled, open, close])
|
|
386
|
+
|
|
387
|
+
// 使用了皮肤则退回原生select样式防止样式冲突。
|
|
388
|
+
if (isSkinActive()) {
|
|
389
|
+
return (
|
|
390
|
+
<select
|
|
391
|
+
id={id}
|
|
392
|
+
className={css.select}
|
|
393
|
+
value={value}
|
|
394
|
+
disabled={props.disabled}
|
|
395
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
396
|
+
>
|
|
397
|
+
{options.map(option => (
|
|
398
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const label = options.find(option => option.value === value)?.label ?? ''
|
|
405
|
+
const popupClass = closing
|
|
406
|
+
? `${css.selectPopup} ${css.selectPopupClose}`
|
|
407
|
+
: phase === 'open'
|
|
408
|
+
? `${css.selectPopup} ${css.selectPopupOpen}`
|
|
409
|
+
: css.selectPopup
|
|
410
|
+
return (
|
|
411
|
+
<div className={css.selectWrap} ref={wrapRef}>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
id={id}
|
|
415
|
+
className={`${css.select} ${css.selectButton}`}
|
|
416
|
+
disabled={props.disabled}
|
|
417
|
+
aria-haspopup="listbox"
|
|
418
|
+
aria-expanded={open}
|
|
419
|
+
aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
|
|
420
|
+
aria-invalid={props.invalid || undefined}
|
|
421
|
+
onClick={onTriggerClick}
|
|
422
|
+
onKeyDown={onKeyDown}
|
|
423
|
+
>
|
|
424
|
+
<span className={css.selectLabel}>{label}</span>
|
|
425
|
+
<svg
|
|
426
|
+
width="14"
|
|
427
|
+
height="14"
|
|
428
|
+
viewBox="0 0 14 14"
|
|
429
|
+
fill="none"
|
|
430
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
431
|
+
className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
>
|
|
434
|
+
<path
|
|
435
|
+
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
436
|
+
fill="currentColor"
|
|
437
|
+
/>
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
{open
|
|
441
|
+
? (
|
|
442
|
+
<div className={popupClass} role="listbox" ref={popupRef}>
|
|
443
|
+
{options.map((option, index) => (
|
|
444
|
+
<div
|
|
445
|
+
key={option.value}
|
|
446
|
+
id={`${id}-o${index}`}
|
|
447
|
+
role="option"
|
|
448
|
+
aria-selected={option.value === value}
|
|
449
|
+
className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
|
|
450
|
+
onClick={() => { commit(index) }}
|
|
451
|
+
>
|
|
452
|
+
{option.label}
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
: null}
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
248
462
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
249
463
|
export function BooleanField(props: FieldProps & {
|
|
250
464
|
/** Copy for the inherit option. */
|
|
@@ -274,17 +488,18 @@ export function BooleanField(props: FieldProps & {
|
|
|
274
488
|
)
|
|
275
489
|
: null}
|
|
276
490
|
</div>
|
|
277
|
-
<
|
|
491
|
+
<SelectField
|
|
278
492
|
id={props.id}
|
|
279
|
-
|
|
493
|
+
options={[
|
|
494
|
+
{ value: '', label: props.inheritLabel },
|
|
495
|
+
{ value: 'true', label: props.onLabel },
|
|
496
|
+
{ value: 'false', label: props.offLabel },
|
|
497
|
+
]}
|
|
280
498
|
value={props.text}
|
|
281
499
|
disabled={props.disabled}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<option value="true">{props.onLabel}</option>
|
|
286
|
-
<option value="false">{props.offLabel}</option>
|
|
287
|
-
</select>
|
|
500
|
+
invalid={props.invalid}
|
|
501
|
+
onEdit={props.onEdit}
|
|
502
|
+
/>
|
|
288
503
|
<p className={css.hint}>{props.hint}</p>
|
|
289
504
|
</div>
|
|
290
505
|
)
|
|
@@ -317,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
|
|
|
317
532
|
)
|
|
318
533
|
: null}
|
|
319
534
|
</div>
|
|
320
|
-
<
|
|
535
|
+
<SelectField
|
|
321
536
|
id={props.id}
|
|
322
|
-
|
|
537
|
+
options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
|
|
323
538
|
value={props.text}
|
|
324
539
|
disabled={props.disabled}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
{props.choices.map(choice => (
|
|
329
|
-
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
330
|
-
))}
|
|
331
|
-
</select>
|
|
540
|
+
invalid={props.invalid}
|
|
541
|
+
onEdit={props.onEdit}
|
|
542
|
+
/>
|
|
332
543
|
<p className={props.invalid ? css.invalid : css.hint}>
|
|
333
544
|
{props.invalid ? props.invalidLabel : props.hint}
|
|
334
545
|
</p>
|