@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PetDockEntry.d.ts","sourceRoot":"","sources":["../../../src/client/PetDockEntry.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAmC,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAEnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEtD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,2DAA2D;AAC3D,MAAM,WAAW,WAAW;IAC1B,6EAA6E;IAC7E,KAAK,EAAE,gBAAgB,CAAA;IACvB,kFAAkF;IAClF,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,IAAI,CAAA;IACf,uBAAuB;IACvB,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,uBAAuB;IACvB,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,qCAAqC;IACrC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAChD,uDAAuD;IACvD,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,2DAA2D;IAC3D,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IACxC,iCAAiC;IACjC,YAAY,EAAE,MAAM,IAAI,CAAA;CACzB;AAED,yFAAyF;AACzF,MAAM,MAAM,iBAAiB,GAC3B,WAAW,GACT,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;AAI1B;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,YAAY,
|
|
1
|
+
{"version":3,"file":"PetDockEntry.d.ts","sourceRoot":"","sources":["../../../src/client/PetDockEntry.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAmC,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAEnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEtD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,2DAA2D;AAC3D,MAAM,WAAW,WAAW;IAC1B,6EAA6E;IAC7E,KAAK,EAAE,gBAAgB,CAAA;IACvB,kFAAkF;IAClF,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,IAAI,CAAA;IACf,uBAAuB;IACvB,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,uBAAuB;IACvB,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,qCAAqC;IACrC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAChD,uDAAuD;IACvD,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,2DAA2D;IAC3D,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IACxC,iCAAiC;IACjC,YAAY,EAAE,MAAM,IAAI,CAAA;CACzB;AAED,yFAAyF;AACzF,MAAM,MAAM,iBAAiB,GAC3B,WAAW,GACT,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;AAI1B;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,YAAY,CAsDnE"}
|
|
@@ -43,5 +43,5 @@ export function PetDockEntry(props) {
|
|
|
43
43
|
right: display.right,
|
|
44
44
|
bottom: display.bottom,
|
|
45
45
|
zIndex: 2147483000,
|
|
46
|
-
}, onClick: props.summon, "data-testid": "pet-summon", children: props.t('pet.summon', { name: snapshot?.name ?? '' }) }));
|
|
46
|
+
}, onClick: props.summon, "data-testid": "pet-summon", "data-dsh-part": "summon-button", children: props.t('pet.summon', { name: snapshot?.name ?? '' }) }));
|
|
47
47
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAoD,WAAW,EAAE,MAAM,OAAO,CAAA;AAG1F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AAOD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CAud5D"}
|
|
@@ -9,10 +9,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
9
9
|
* pet id), so one component renders every registry entry.
|
|
10
10
|
* @module @linxin666/dsh-pet/client/PetSprite
|
|
11
11
|
*/
|
|
12
|
-
import { useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
13
13
|
import { createPortal } from 'react-dom';
|
|
14
14
|
import clsx from 'clsx';
|
|
15
15
|
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.js';
|
|
16
|
+
import { sequenceFrameAt } from './sequences.js';
|
|
17
|
+
import { animationForPhase } from '../state.js';
|
|
16
18
|
import styles from './pet.module.css';
|
|
17
19
|
/** Clamp a drag offset inside the viewport with a margin. */
|
|
18
20
|
function clampOffset(value, max) {
|
|
@@ -28,10 +30,29 @@ export function PetSprite(props) {
|
|
|
28
30
|
const { snapshot, definition, display, feedback } = props;
|
|
29
31
|
const spriteRef = useRef(null);
|
|
30
32
|
const floatRef = useRef(null);
|
|
33
|
+
const panelRef = useRef(null);
|
|
34
|
+
// Whichever bubble surface is currently rendered (feedback, the session
|
|
35
|
+
// stack, or the legacy status bubble) — only one exists at a time.
|
|
36
|
+
const bubbleRef = useRef(null);
|
|
31
37
|
const [imageReady, setImageReady] = useState(false);
|
|
32
38
|
const [hovered, setHovered] = useState(false);
|
|
39
|
+
// Multi-session bubble stack: collapsed by default (only the display
|
|
40
|
+
// session's bubble + a '+N' badge), expanded on stack hover (peek) or by
|
|
41
|
+
// tapping the badge (pinned, for touch). The display session's bubble
|
|
42
|
+
// anchors the bottom of the stack and never moves when extras open above
|
|
43
|
+
// it, so the pointer target cannot flicker.
|
|
44
|
+
const [stackPeek, setStackPeek] = useState(false);
|
|
45
|
+
const [stackPinned, setStackPinned] = useState(false);
|
|
33
46
|
const [renaming, setRenaming] = useState(false);
|
|
47
|
+
const [panelAbove, setPanelAbove] = useState(false);
|
|
48
|
+
// Extra margin-bottom for the above-panel so it stacks clear of the
|
|
49
|
+
// bubbles instead of overlapping them (both anchor at the sprite's top).
|
|
50
|
+
const [panelLift, setPanelLift] = useState(0);
|
|
34
51
|
const [nameDraft, setNameDraft] = useState('');
|
|
52
|
+
// Explicit IME composition tracking: some input methods (WeChat IME on
|
|
53
|
+
// Windows) report keydowns with isComposing === false mid-composition, so
|
|
54
|
+
// the native flag alone is not a safe submit/cancel guard (#303).
|
|
55
|
+
const composingRef = useRef(false);
|
|
35
56
|
const [dragPos, setDragPos] = useState(null);
|
|
36
57
|
const dragRef = useRef(null);
|
|
37
58
|
const hideTimerRef = useRef(null);
|
|
@@ -44,6 +65,7 @@ export function PetSprite(props) {
|
|
|
44
65
|
const columns = definition.columns;
|
|
45
66
|
const rows = definition.rows;
|
|
46
67
|
const tracks = definition.tracks;
|
|
68
|
+
const sequences = definition.sequences;
|
|
47
69
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
48
70
|
// frame counts and per-track durations, so nothing else is fetched.
|
|
49
71
|
useEffect(() => {
|
|
@@ -66,14 +88,17 @@ export function PetSprite(props) {
|
|
|
66
88
|
// its track's first frame instead of animating (presentation-only; the
|
|
67
89
|
// animation state machine is untouched).
|
|
68
90
|
const spriteScale = display.size / cell.height;
|
|
91
|
+
const phase = snapshot?.phase ?? 'idle';
|
|
69
92
|
const animation = snapshot?.animation ?? 'idle';
|
|
70
93
|
const scaleRef = useRef(spriteScale);
|
|
71
94
|
scaleRef.current = spriteScale;
|
|
72
95
|
useEffect(() => {
|
|
73
96
|
const reduceMotion = typeof window !== 'undefined'
|
|
74
97
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
|
|
75
|
-
const
|
|
76
|
-
const
|
|
98
|
+
const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined;
|
|
99
|
+
const leadAnimation = sequence?.[0] ?? animation;
|
|
100
|
+
const row = rowOfTrack(leadAnimation);
|
|
101
|
+
const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length);
|
|
77
102
|
// Paint one static sprite frame up front either way, so the pet is never
|
|
78
103
|
// blank while the loop heat-up runs.
|
|
79
104
|
const leadCol = track.frames[0];
|
|
@@ -85,9 +110,23 @@ export function PetSprite(props) {
|
|
|
85
110
|
return;
|
|
86
111
|
let raf = 0;
|
|
87
112
|
let last = performance.now();
|
|
113
|
+
let sequenceElapsed = 0;
|
|
88
114
|
const tick = (ts) => {
|
|
89
115
|
const delta = ts - last;
|
|
90
116
|
last = ts;
|
|
117
|
+
if (sequence !== undefined) {
|
|
118
|
+
sequenceElapsed += delta;
|
|
119
|
+
const current = sequenceFrameAt(sequence, tracks, sequenceElapsed);
|
|
120
|
+
const currentRow = rowOfTrack(current.animation);
|
|
121
|
+
const currentTrack = trimTrack(tracks[current.animation], rows[currentRow] ?? tracks[current.animation].frames.length);
|
|
122
|
+
const col = currentTrack.frames[current.frameIndex];
|
|
123
|
+
const pos = framePosition(cell, columns, currentRow, col, scaleRef.current);
|
|
124
|
+
if (spriteRef.current !== null) {
|
|
125
|
+
spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px';
|
|
126
|
+
}
|
|
127
|
+
raf = requestAnimationFrame(tick);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
91
130
|
// row/track come from the effect scope: they were computed once above
|
|
92
131
|
// and this effect re-runs when animation/tracks/rows change, so the
|
|
93
132
|
// per-frame recompute (trimTrack slices fresh arrays) is pure waste.
|
|
@@ -121,7 +160,7 @@ export function PetSprite(props) {
|
|
|
121
160
|
};
|
|
122
161
|
raf = requestAnimationFrame(tick);
|
|
123
162
|
return () => cancelAnimationFrame(raf);
|
|
124
|
-
}, [animation, cell, columns, rows, tracks]);
|
|
163
|
+
}, [animation, phase, cell, columns, rows, tracks, sequences]);
|
|
125
164
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
126
165
|
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
127
166
|
// `props` every tick, and depending on it would starve the timeout.
|
|
@@ -143,6 +182,10 @@ export function PetSprite(props) {
|
|
|
143
182
|
hideTimerRef.current = null;
|
|
144
183
|
}
|
|
145
184
|
};
|
|
185
|
+
// Clear any pending auto-hide timer on unmount: a stray callback after
|
|
186
|
+
// teardown reads window through react-dom and failed CI runs with
|
|
187
|
+
// "window is not defined" (slow-runner timing, PetSprite.test.tsx).
|
|
188
|
+
useEffect(() => () => clearHideTimer(), []);
|
|
146
189
|
const onPointerDown = (e) => {
|
|
147
190
|
e.preventDefault();
|
|
148
191
|
e.target.setPointerCapture?.(e.pointerId);
|
|
@@ -173,37 +216,84 @@ export function PetSprite(props) {
|
|
|
173
216
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom };
|
|
174
217
|
const spriteWidth = Math.round(cell.width * spriteScale);
|
|
175
218
|
const spriteHeight = Math.round(cell.height * spriteScale);
|
|
176
|
-
// Concurrent sessions
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
219
|
+
// Concurrent sessions share one bubble slot: only the display session
|
|
220
|
+
// speaks by default, and the rest hide behind a '+N' badge until the stack
|
|
221
|
+
// is hovered/pinned open. The legacy single 'bubble' is the fallback when
|
|
222
|
+
// the host serves no per-session list. The hover panel normally sits below
|
|
223
|
+
// the sprite, so the bubbles stay visible and clickable — no region swap.
|
|
180
224
|
const sessionBubbles = snapshot?.sessions ?? [];
|
|
225
|
+
const stackOpen = stackPeek || stackPinned;
|
|
226
|
+
const collapsed = !stackOpen && sessionBubbles.length > 1;
|
|
227
|
+
const visibleSessions = collapsed ? sessionBubbles.slice(0, 1) : sessionBubbles;
|
|
181
228
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
182
229
|
? snapshot?.bubble
|
|
183
230
|
: undefined;
|
|
231
|
+
// The display session's inner whisper (碎碎念) — short inner-voice copy
|
|
232
|
+
// woken by the model's output. Instead of a second bubble of its own, a
|
|
233
|
+
// fresh whisper takes over the display session's bubble (the stack top, or
|
|
234
|
+
// the single status bubble) and re-tints it, so the pet never wears two
|
|
235
|
+
// voices at once. Interaction feedback takes over the whole bubble area
|
|
236
|
+
// while it plays, so whispers yield to it like status copy.
|
|
237
|
+
const whisper = feedback === null ? snapshot?.whisper : undefined;
|
|
238
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined;
|
|
184
239
|
const displayName = snapshot?.name ?? definition.displayName;
|
|
240
|
+
// A settled session list can no longer stay pinned open.
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (sessionBubbles.length <= 1)
|
|
243
|
+
setStackPinned(false);
|
|
244
|
+
}, [sessionBubbles.length]);
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
if (!hovered) {
|
|
247
|
+
setPanelAbove(false);
|
|
248
|
+
setPanelLift(0);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const updatePanelPlacement = () => {
|
|
252
|
+
const sprite = spriteRef.current;
|
|
253
|
+
const panel = panelRef.current;
|
|
254
|
+
if (sprite === null || panel === null)
|
|
255
|
+
return;
|
|
256
|
+
const availableBelow = window.innerHeight - sprite.getBoundingClientRect().bottom;
|
|
257
|
+
const above = availableBelow < panel.getBoundingClientRect().height + 8;
|
|
258
|
+
setPanelAbove(above);
|
|
259
|
+
// The fallback above-placement shares the sprite's top edge with the
|
|
260
|
+
// bubble(s); lift the panel by the bubble area's height so the two
|
|
261
|
+
// never overlap (8px base gap + 6px clearance above the top bubble).
|
|
262
|
+
const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0;
|
|
263
|
+
setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0);
|
|
264
|
+
};
|
|
265
|
+
updatePanelPlacement();
|
|
266
|
+
window.addEventListener('resize', updatePanelPlacement);
|
|
267
|
+
return () => window.removeEventListener('resize', updatePanelPlacement);
|
|
268
|
+
}, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, stackOpen, feedback]);
|
|
185
269
|
const float = (_jsxs("div", { ref: floatRef, className: styles.float, style: { right: pos.right, bottom: pos.bottom, zIndex: 2147483000 }, onPointerEnter: () => {
|
|
186
270
|
clearHideTimer();
|
|
187
271
|
setHovered(true);
|
|
188
272
|
}, onPointerLeave: (e) => {
|
|
189
|
-
// The panel renders OUTSIDE the container's box (absolute,
|
|
273
|
+
// The panel renders OUTSIDE the container's box (absolute, below
|
|
190
274
|
// the sprite), so moving onto it fires pointerleave on the container.
|
|
191
275
|
// Treat a target still inside the container's DOM (the overflowed
|
|
192
276
|
// panel) as "still hovering"; otherwise give the pointer a short
|
|
193
|
-
// grace period to reach the panel across the gap
|
|
277
|
+
// grace period to reach the panel across the gap below the sprite.
|
|
194
278
|
// The bridge ('.panel::after') keeps the pointer inside the hit
|
|
195
279
|
// area, and the grace period covers a slow mouse crossing the
|
|
196
280
|
// remaining sliver.
|
|
197
281
|
const next = e.relatedTarget;
|
|
198
282
|
if (next instanceof Node && floatRef.current?.contains(next))
|
|
199
283
|
return;
|
|
284
|
+
// Never auto-hide while the rename box is open: moving the pointer
|
|
285
|
+
// onto an IME candidate window (an OS-level window outside the
|
|
286
|
+
// webview) fires pointerleave, and unmounting the input mid-IME-
|
|
287
|
+
// composition crashes some input methods / the renderer (#303).
|
|
288
|
+
if (renaming)
|
|
289
|
+
return;
|
|
200
290
|
clearHideTimer();
|
|
201
291
|
hideTimerRef.current = window.setTimeout(() => setHovered(false), 300);
|
|
202
292
|
}, children: [_jsx("div", { ref: spriteRef, className: styles.sprite, style: {
|
|
203
293
|
width: spriteWidth,
|
|
204
294
|
height: spriteHeight,
|
|
205
295
|
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
206
|
-
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
|
|
296
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
207
297
|
backgroundRepeat: 'no-repeat',
|
|
208
298
|
backgroundPosition: '0 0',
|
|
209
299
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
@@ -213,17 +303,41 @@ export function PetSprite(props) {
|
|
|
213
303
|
if (draggedRef.current)
|
|
214
304
|
return;
|
|
215
305
|
props.onPet();
|
|
216
|
-
}, role: "button", "aria-label": definition.displayName }), feedback !== null && (_jsx("div", { className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && sessionBubbles.length > 0
|
|
306
|
+
}, role: "button", "aria-label": definition.displayName }), feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
|
|
307
|
+
// The whisper rides the display session's bubble — the stack's
|
|
308
|
+
// primary entry (DOM-first, rendered bottom-most by the reversed
|
|
309
|
+
// column so it stays glued to the sprite when extras open above).
|
|
310
|
+
// The key swap restarts the entrance animation so the mood change
|
|
311
|
+
// reads as the bubble re-speaking.
|
|
312
|
+
const speaksWhisper = index === 0 && whisper !== undefined;
|
|
313
|
+
const bubble = (_jsx("button", { type: "button", className: clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable, speaksWhisper && styles.bubbleWhisper), title: props.t('pet.openSessionHint'), onClick: () => { props.onOpenSession(session.sessionId); }, children: speaksWhisper ? whisper : session.bubble }, speaksWhisper ? 'whisper:' + whisper : session.sessionId));
|
|
314
|
+
// The primary bubble carries the '+N' badge while other sessions
|
|
315
|
+
// hide behind it; the badge toggles the pinned (touch) expansion.
|
|
316
|
+
if (index !== 0 || sessionBubbles.length <= 1)
|
|
317
|
+
return bubble;
|
|
318
|
+
return (_jsxs("span", { className: styles.bubbleAnchor, children: [bubble, _jsx("button", { type: "button", className: styles.bubbleMore, title: stackOpen
|
|
319
|
+
? props.t('pet.collapseSessions')
|
|
320
|
+
: props.t('pet.moreSessions', { n: sessionBubbles.length - 1 }), "aria-label": stackOpen
|
|
321
|
+
? props.t('pet.collapseSessions')
|
|
322
|
+
: props.t('pet.moreSessions', { n: sessionBubbles.length - 1 }), "aria-expanded": stackOpen, onClick: (e) => {
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
setStackPinned(open => !open);
|
|
325
|
+
}, children: stackOpen ? '×' : '+' + String(sessionBubbles.length - 1) })] }, "primary"));
|
|
326
|
+
}), sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (_jsx("div", { className: clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper), role: "status", "aria-live": "polite", children: whisper ?? statusBubble }, whisper === undefined ? 'status' : 'whisper:' + whisper))] })), hovered && dragRef.current === null && (_jsx("div", { ref: panelRef, className: clsx(styles.panel, panelAbove && styles.panelAbove), "data-placement": panelAbove ? 'above' : 'below', style: panelAbove && panelLift > 0
|
|
327
|
+
? { marginBottom: panelLift }
|
|
328
|
+
: undefined, onPointerEnter: () => {
|
|
217
329
|
// Reaching the panel (or its bridge) must cancel any hide timer
|
|
218
330
|
// the container's pointerleave may have armed while the pointer
|
|
219
331
|
// crossed the sliver between the sprite and the panel.
|
|
220
332
|
clearHideTimer();
|
|
221
|
-
}, children: renaming ? (_jsxs("div", { className: styles.renameRow, children: [_jsx("input", { className: styles.nameInput, value: nameDraft, maxLength: 20, placeholder: props.t('pet.namePlaceholder'), autoFocus: true, onChange: (e) => setNameDraft(e.target.value), onKeyDown: (e) => {
|
|
333
|
+
}, children: renaming ? (_jsxs("div", { className: styles.renameRow, children: [_jsx("input", { className: styles.nameInput, value: nameDraft, maxLength: 20, placeholder: props.t('pet.namePlaceholder'), autoFocus: true, onChange: (e) => setNameDraft(e.target.value), onCompositionStart: () => { composingRef.current = true; }, onCompositionEnd: () => { composingRef.current = false; }, onKeyDown: (e) => {
|
|
222
334
|
// While an IME composition is active (e.g. selecting a
|
|
223
335
|
// Chinese candidate), Enter/Escape keydowns belong to the
|
|
224
336
|
// input method: ignore them so candidate selection can
|
|
225
|
-
// neither submit the draft nor close the rename box.
|
|
226
|
-
|
|
337
|
+
// neither submit the draft nor close the rename box. The
|
|
338
|
+
// explicit ref and the 'Process' key cover IMEs that mark
|
|
339
|
+
// composition keydowns with isComposing === false (#303).
|
|
340
|
+
if (composingRef.current || e.nativeEvent.isComposing || e.key === 'Process')
|
|
227
341
|
return;
|
|
228
342
|
if (e.key === 'Enter') {
|
|
229
343
|
const trimmed = nameDraft.trim();
|
|
@@ -241,7 +355,10 @@ export function PetSprite(props) {
|
|
|
241
355
|
props.onRename(trimmed);
|
|
242
356
|
setRenaming(false);
|
|
243
357
|
}
|
|
244
|
-
}, children: props.t('pet.confirm') })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.nameCell, children: displayName }), _jsx("span", { children: props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' }) })] }), _jsxs("div", { className: styles.rankRow, children: [_jsx("span", { children: props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 }) }), _jsx("span", { children: props.t('pet.points', { points: snapshot?.affinity.points ?? 0 }) })] }), _jsxs("div", { className: styles.actions, children: [_jsx("button", { type: "button", className: styles.action, onClick: props.onFeed, children: props.t('pet.feed') }), _jsx("button", { type: "button", className: styles.action, onClick: () => {
|
|
358
|
+
}, children: props.t('pet.confirm') })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.nameCell, children: displayName }), _jsx("span", { className: styles.statRank, children: props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' }) })] }), _jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.statTreats, children: props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 }) }), _jsx("span", { className: styles.statPoints, children: props.t('pet.points', { points: snapshot?.affinity.points ?? 0 }) })] }), _jsxs("div", { className: styles.actions, children: [_jsx("button", { type: "button", className: styles.action, onClick: props.onFeed, children: props.t('pet.feed') }), _jsx("button", { type: "button", className: styles.action, onClick: () => {
|
|
359
|
+
// Cancel any pending hide so the rename box cannot
|
|
360
|
+
// unmount right as the user starts typing (#303).
|
|
361
|
+
clearHideTimer();
|
|
245
362
|
setNameDraft(displayName);
|
|
246
363
|
setRenaming(true);
|
|
247
364
|
}, children: props.t('pet.rename') }), _jsx("button", { type: "button", className: styles.action, onClick: props.onHide, children: props.t('pet.hide') })] })] })) }))] }));
|
|
@@ -39,6 +39,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
39
39
|
* already provides the selection.
|
|
40
40
|
*/
|
|
41
41
|
alwaysOpen?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Hide the save/discard footer: cards whose body applies its own changes
|
|
44
|
+
* immediately (an embedded external settings section) have no staged
|
|
45
|
+
* edits, so the footer would sit there permanently disabled.
|
|
46
|
+
*/
|
|
47
|
+
hideFooter?: boolean;
|
|
42
48
|
/** The plugin's controls. */
|
|
43
49
|
children: ReactNode;
|
|
44
50
|
}
|
|
@@ -82,6 +88,27 @@ export declare function ValueField(props: FieldProps & {
|
|
|
82
88
|
/** Placeholder shown while the draft is empty. */
|
|
83
89
|
placeholder?: string;
|
|
84
90
|
}): import("react").JSX.Element;
|
|
91
|
+
interface SelectOption {
|
|
92
|
+
value: string;
|
|
93
|
+
label: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
97
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
98
|
+
* selectors keep working; under the default appearance it renders a
|
|
99
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
100
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
101
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
102
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
103
|
+
*/
|
|
104
|
+
export declare function SelectField(props: {
|
|
105
|
+
id: string;
|
|
106
|
+
options: ReadonlyArray<SelectOption>;
|
|
107
|
+
value: string;
|
|
108
|
+
disabled: boolean;
|
|
109
|
+
invalid: boolean;
|
|
110
|
+
onEdit: (text: string) => void;
|
|
111
|
+
}): import("react").JSX.Element;
|
|
85
112
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
86
113
|
export declare function BooleanField(props: FieldProps & {
|
|
87
114
|
/** Copy for the inherit option. */
|
|
@@ -101,4 +128,5 @@ export declare function ChoiceField(props: FieldProps & {
|
|
|
101
128
|
label: string;
|
|
102
129
|
}>;
|
|
103
130
|
}): import("react").JSX.Element;
|
|
131
|
+
export {};
|
|
104
132
|
//# sourceMappingURL=PluginSettingsCard.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PluginSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PluginSettingsCard.tsx"],"names":[],"mappings":"AACA;;;;;;;GAOG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"PluginSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PluginSettingsCard.tsx"],"names":[],"mappings":"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"}
|
|
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
* it. Inlined into each consumer's client bundle; mirrors the official
|
|
9
9
|
* ui-plugin-config PluginCard in a self-contained slice.
|
|
10
10
|
*/
|
|
11
|
-
import { useState } from 'react';
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
12
12
|
import css from './settings-card.module.css';
|
|
13
13
|
/** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
|
|
14
14
|
export const CARD_COPY_KEYS = [
|
|
@@ -52,9 +52,11 @@ export function PluginSettingsCard(props) {
|
|
|
52
52
|
: null] }));
|
|
53
53
|
}
|
|
54
54
|
return (_jsxs("li", { className: cardClass, children: [header, expanded
|
|
55
|
-
? (_jsxs("div", { className: css.body, children: [!state.writable ? _jsx("p", { className: css.readOnly, role: "status", children: props.t('settings.readOnly') }) : null, props.children,
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
? (_jsxs("div", { className: css.body, children: [!state.writable ? _jsx("p", { className: css.readOnly, role: "status", children: props.t('settings.readOnly') }) : null, props.children, props.hideFooter === true
|
|
56
|
+
? null
|
|
57
|
+
: (_jsxs("div", { className: css.footer, children: [state.failed
|
|
58
|
+
? (_jsxs("p", { className: css.failed, role: "status", children: [props.t('settings.saveFailed'), state.failedReason ? ' - ' + state.failedReason : ''] }))
|
|
59
|
+
: null, _jsx("button", { type: "button", className: css.discard, disabled: !state.dirty || state.saving, onClick: props.onDiscard, children: props.t('settings.discard') }), _jsx("button", { type: "button", className: css.save, disabled: blocked, onClick: props.onSave, children: props.t(!state.saving ? 'settings.save' : 'settings.saving') })] }))] }))
|
|
58
60
|
: null] }));
|
|
59
61
|
}
|
|
60
62
|
/** A staged value field. `numeric` only hints the keypad: which drafts a field accepts is decided by its spec. */
|
|
@@ -63,15 +65,154 @@ export function ValueField(props) {
|
|
|
63
65
|
? (_jsxs("span", { className: css.badges, children: [_jsx("span", { className: css.badge, children: props.overriddenLabel }), _jsx("button", { type: "button", className: css.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel })] }))
|
|
64
66
|
: null] }), _jsx("input", { id: props.id, className: props.invalid ? css.inputInvalid : css.input, type: "text", ...props.numeric === true ? { inputMode: 'numeric' } : {}, ...props.invalid ? { 'aria-invalid': true } : {}, value: props.text, placeholder: props.placeholder ?? '', disabled: props.disabled, onChange: (event) => { props.onEdit(event.target.value); } }), _jsx("p", { className: props.invalid ? css.invalid : css.hint, children: props.invalid ? props.invalidLabel : props.hint })] }));
|
|
65
67
|
}
|
|
68
|
+
const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed']);
|
|
69
|
+
// 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
|
|
70
|
+
function isSkinActive() {
|
|
71
|
+
const datasetList = Object.keys(document.body.dataset);
|
|
72
|
+
const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key));
|
|
73
|
+
return isActive;
|
|
74
|
+
}
|
|
75
|
+
const SELECT_CLOSE_MS = 100;
|
|
76
|
+
/**
|
|
77
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
78
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
79
|
+
* selectors keep working; under the default appearance it renders a
|
|
80
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
81
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
82
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
83
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
84
|
+
*/
|
|
85
|
+
export function SelectField(props) {
|
|
86
|
+
const { id, options, value } = props;
|
|
87
|
+
const [open, setOpen] = useState(false);
|
|
88
|
+
const [closing, setClosing] = useState(false);
|
|
89
|
+
const [phase, setPhase] = useState('initial');
|
|
90
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
91
|
+
const closeTimer = useRef(undefined);
|
|
92
|
+
const wrapRef = useRef(null);
|
|
93
|
+
const popupRef = useRef(null);
|
|
94
|
+
const currentIndex = () => {
|
|
95
|
+
const index = options.findIndex(option => option.value === value);
|
|
96
|
+
return index >= 0 ? index : 0;
|
|
97
|
+
};
|
|
98
|
+
const close = useCallback(() => {
|
|
99
|
+
if (closeTimer.current !== undefined)
|
|
100
|
+
clearTimeout(closeTimer.current);
|
|
101
|
+
setClosing(true);
|
|
102
|
+
closeTimer.current = setTimeout(() => {
|
|
103
|
+
setClosing(false);
|
|
104
|
+
setOpen(false);
|
|
105
|
+
}, SELECT_CLOSE_MS);
|
|
106
|
+
}, []);
|
|
107
|
+
const openPopup = () => {
|
|
108
|
+
if (closeTimer.current !== undefined)
|
|
109
|
+
clearTimeout(closeTimer.current);
|
|
110
|
+
setActiveIndex(currentIndex());
|
|
111
|
+
setPhase('initial');
|
|
112
|
+
setClosing(false);
|
|
113
|
+
setOpen(true);
|
|
114
|
+
};
|
|
115
|
+
const commit = (index) => {
|
|
116
|
+
const option = options[index];
|
|
117
|
+
if (option)
|
|
118
|
+
props.onEdit(option.value);
|
|
119
|
+
close();
|
|
120
|
+
};
|
|
121
|
+
const onTriggerClick = () => {
|
|
122
|
+
if (props.disabled)
|
|
123
|
+
return;
|
|
124
|
+
if (open && !closing)
|
|
125
|
+
close();
|
|
126
|
+
else
|
|
127
|
+
openPopup();
|
|
128
|
+
};
|
|
129
|
+
const onKeyDown = (event) => {
|
|
130
|
+
if (props.disabled)
|
|
131
|
+
return;
|
|
132
|
+
const count = options.length;
|
|
133
|
+
switch (event.key) {
|
|
134
|
+
case 'ArrowDown':
|
|
135
|
+
case 'ArrowUp':
|
|
136
|
+
case 'Enter':
|
|
137
|
+
case ' ':
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
if (!open) {
|
|
140
|
+
openPopup();
|
|
141
|
+
}
|
|
142
|
+
else if (!closing) {
|
|
143
|
+
if (event.key === 'ArrowDown')
|
|
144
|
+
setActiveIndex(index => (index + 1) % count);
|
|
145
|
+
else if (event.key === 'ArrowUp')
|
|
146
|
+
setActiveIndex(index => (index - 1 + count) % count);
|
|
147
|
+
else
|
|
148
|
+
commit(activeIndex);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case 'Escape':
|
|
152
|
+
if (open) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
event.stopPropagation();
|
|
155
|
+
close();
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case 'Tab':
|
|
159
|
+
if (open)
|
|
160
|
+
close();
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
useEffect(() => () => {
|
|
165
|
+
if (closeTimer.current !== undefined)
|
|
166
|
+
clearTimeout(closeTimer.current);
|
|
167
|
+
}, []);
|
|
168
|
+
useLayoutEffect(() => {
|
|
169
|
+
if (open && !closing && phase === 'initial') {
|
|
170
|
+
void popupRef.current?.offsetHeight;
|
|
171
|
+
setPhase('open');
|
|
172
|
+
}
|
|
173
|
+
}, [open, closing, phase]);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!open)
|
|
176
|
+
return;
|
|
177
|
+
const onPointerDown = (event) => {
|
|
178
|
+
const target = event.target;
|
|
179
|
+
if (target instanceof Node && !wrapRef.current?.contains(target))
|
|
180
|
+
close();
|
|
181
|
+
};
|
|
182
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
183
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
184
|
+
}, [open, close]);
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (props.disabled && open)
|
|
187
|
+
close();
|
|
188
|
+
}, [props.disabled, open, close]);
|
|
189
|
+
// 使用了皮肤则退回原生select样式防止样式冲突。
|
|
190
|
+
if (isSkinActive()) {
|
|
191
|
+
return (_jsx("select", { id: id, className: css.select, value: value, disabled: props.disabled, onChange: (event) => { props.onEdit(event.target.value); }, children: options.map(option => (_jsx("option", { value: option.value, children: option.label }, option.value))) }));
|
|
192
|
+
}
|
|
193
|
+
const label = options.find(option => option.value === value)?.label ?? '';
|
|
194
|
+
const popupClass = closing
|
|
195
|
+
? `${css.selectPopup} ${css.selectPopupClose}`
|
|
196
|
+
: phase === 'open'
|
|
197
|
+
? `${css.selectPopup} ${css.selectPopupOpen}`
|
|
198
|
+
: css.selectPopup;
|
|
199
|
+
return (_jsxs("div", { className: css.selectWrap, ref: wrapRef, children: [_jsxs("button", { type: "button", id: id, className: `${css.select} ${css.selectButton}`, disabled: props.disabled, "aria-haspopup": "listbox", "aria-expanded": open, "aria-activedescendant": open ? `${id}-o${activeIndex}` : undefined, "aria-invalid": props.invalid || undefined, onClick: onTriggerClick, onKeyDown: onKeyDown, children: [_jsx("span", { className: css.selectLabel, children: label }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", xmlns: "http://www.w3.org/2000/svg", className: open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron, "aria-hidden": "true", children: _jsx("path", { 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", fill: "currentColor" }) })] }), open
|
|
200
|
+
? (_jsx("div", { className: popupClass, role: "listbox", ref: popupRef, children: options.map((option, index) => (_jsx("div", { id: `${id}-o${index}`, role: "option", "aria-selected": option.value === value, className: `${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`, onClick: () => { commit(index); }, children: option.label }, option.value))) }))
|
|
201
|
+
: null] }));
|
|
202
|
+
}
|
|
66
203
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
67
204
|
export function BooleanField(props) {
|
|
68
205
|
return (_jsxs("div", { className: css.field, children: [_jsxs("div", { className: css.head, children: [_jsx("label", { className: css.label, htmlFor: props.id, children: props.label }), props.overridden
|
|
69
206
|
? (_jsxs("span", { className: css.badges, children: [_jsx("span", { className: css.badge, children: props.overriddenLabel }), _jsx("button", { type: "button", className: css.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel })] }))
|
|
70
|
-
: null] }),
|
|
207
|
+
: null] }), _jsx(SelectField, { id: props.id, options: [
|
|
208
|
+
{ value: '', label: props.inheritLabel },
|
|
209
|
+
{ value: 'true', label: props.onLabel },
|
|
210
|
+
{ value: 'false', label: props.offLabel },
|
|
211
|
+
], value: props.text, disabled: props.disabled, invalid: props.invalid, onEdit: props.onEdit }), _jsx("p", { className: css.hint, children: props.hint })] }));
|
|
71
212
|
}
|
|
72
213
|
/** A staged enumerated field rendered as a select. */
|
|
73
214
|
export function ChoiceField(props) {
|
|
74
215
|
return (_jsxs("div", { className: css.field, children: [_jsxs("div", { className: css.head, children: [_jsx("label", { className: css.label, htmlFor: props.id, children: props.label }), props.overridden
|
|
75
216
|
? (_jsxs("span", { className: css.badges, children: [_jsx("span", { className: css.badge, children: props.overriddenLabel }), _jsx("button", { type: "button", className: css.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel })] }))
|
|
76
|
-
: null] }),
|
|
217
|
+
: null] }), _jsx(SelectField, { id: props.id, options: [{ value: '', label: props.inheritLabel }, ...props.choices], value: props.text, disabled: props.disabled, invalid: props.invalid, onEdit: props.onEdit }), _jsx("p", { className: props.invalid ? css.invalid : css.hint, children: props.invalid ? props.invalidLabel : props.hint })] }));
|
|
77
218
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AA6DnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AA6DnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAkN9C"}
|
|
@@ -241,6 +241,7 @@ export function apply(ctx) {
|
|
|
241
241
|
// when visible, and the hidden-state summon button is fixed-positioned.
|
|
242
242
|
const container = document.createElement('div');
|
|
243
243
|
container.dataset.dshPetRoot = '';
|
|
244
|
+
container.dataset.dshPlugin = 'pet';
|
|
244
245
|
document.body.appendChild(container);
|
|
245
246
|
const petRoot = createRoot(container);
|
|
246
247
|
petRoot.render(createElement(PetDockEntry, { ...injected(), t }));
|
|
@@ -18,6 +18,8 @@ export declare const zh: {
|
|
|
18
18
|
readonly 'pet.state.loading': '宠物正在赶来…';
|
|
19
19
|
readonly 'pet.state.error': '宠物迷路了(连接失败)';
|
|
20
20
|
readonly 'pet.openSessionHint': '点击跳转到对应会话';
|
|
21
|
+
readonly 'pet.moreSessions': '展开其余 {n} 个会话的气泡';
|
|
22
|
+
readonly 'pet.collapseSessions': '收起会话气泡';
|
|
21
23
|
readonly 'settings.title': '宠物';
|
|
22
24
|
readonly 'settings.description': '选择宠物并调整它的显示布局。';
|
|
23
25
|
readonly 'settings.pet': '宠物';
|
|
@@ -62,6 +64,8 @@ export declare const en: {
|
|
|
62
64
|
readonly 'pet.state.loading': 'The pet is on its way…';
|
|
63
65
|
readonly 'pet.state.error': 'The pet is lost (connection failed)';
|
|
64
66
|
readonly 'pet.openSessionHint': 'Click to jump to this session';
|
|
67
|
+
readonly 'pet.moreSessions': 'Expand {n} more session bubbles';
|
|
68
|
+
readonly 'pet.collapseSessions': 'Collapse session bubbles';
|
|
65
69
|
readonly 'settings.title': 'Pet';
|
|
66
70
|
readonly 'settings.description': 'Pick a pet and tune its display layout.';
|
|
67
71
|
readonly 'settings.pet': 'Pet';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mDAAmD;AACnD,eAAO,MAAM,EAAE,QAAQ,CAAA;AAEvB,oBAAoB;AACpB,eAAO,MAAM,EAAE;aACb,UAAU,EAAE,IAAI;aAChB,UAAU,EAAE,IAAI;aAChB,YAAY,EAAE,IAAI;aAClB,aAAa,EAAE,IAAI;aACnB,qBAAqB,EAAE,OAAO;aAC9B,YAAY,EAAE,UAAU;aACxB,UAAU,EAAE,YAAY;aACxB,YAAY,EAAE,YAAY;aAC1B,YAAY,EAAE,UAAU;aACxB,mBAAmB,EAAE,SAAS;aAC9B,iBAAiB,EAAE,aAAa;aAChC,qBAAqB,EAAE,WAAW;
|
|
1
|
+
{"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"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"}
|
|
@@ -18,6 +18,8 @@ export const zh = {
|
|
|
18
18
|
'pet.state.loading': '宠物正在赶来…',
|
|
19
19
|
'pet.state.error': '宠物迷路了(连接失败)',
|
|
20
20
|
'pet.openSessionHint': '点击跳转到对应会话',
|
|
21
|
+
'pet.moreSessions': '展开其余 {n} 个会话的气泡',
|
|
22
|
+
'pet.collapseSessions': '收起会话气泡',
|
|
21
23
|
// 一级设置页(settings.section 席位)。
|
|
22
24
|
'settings.title': '宠物',
|
|
23
25
|
'settings.description': '选择宠物并调整它的显示布局。',
|
|
@@ -63,6 +65,8 @@ export const en = {
|
|
|
63
65
|
'pet.state.loading': 'The pet is on its way…',
|
|
64
66
|
'pet.state.error': 'The pet is lost (connection failed)',
|
|
65
67
|
'pet.openSessionHint': 'Click to jump to this session',
|
|
68
|
+
'pet.moreSessions': 'Expand {n} more session bubbles',
|
|
69
|
+
'pet.collapseSessions': 'Collapse session bubbles',
|
|
66
70
|
// First-level settings section (the `settings.section` seat).
|
|
67
71
|
'settings.title': 'Pet',
|
|
68
72
|
'settings.description': 'Pick a pet and tune its display layout.',
|