dsh-plugin-lookatstudy 0.14.0 → 0.15.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.md +3 -2
- package/lib/client.js +1633 -46
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +1144 -280
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -4,6 +4,18 @@ window.__ModuleLoader__.load({
|
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
//#region \0rolldown/runtime.js
|
|
8
|
+
var __defProp = Object.defineProperty;
|
|
9
|
+
var __exportAll = (all, no_symbols) => {
|
|
10
|
+
let target = {};
|
|
11
|
+
for (var name in all) __defProp(target, name, {
|
|
12
|
+
get: all[name],
|
|
13
|
+
enumerable: true
|
|
14
|
+
});
|
|
15
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
16
|
+
return target;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
7
19
|
let react = require("react");
|
|
8
20
|
let react_dom_client = require("react-dom/client");
|
|
9
21
|
//#region src/client/styles.ts
|
|
@@ -38,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
38
50
|
every surface family that consumes the inks: .lks-root (dock pill,
|
|
39
51
|
settings) and .lks-tv (toolview cards, which render in the host
|
|
40
52
|
conversation with no .lks-root ancestor). */
|
|
41
|
-
.lks-root,.lks-tv{--lks-warn-ink:color-mix(in srgb,var(--dsw-alias-state-warn-label) 45%,var(--dsw-alias-label-primary));--lks-ok-ink:color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,var(--dsw-alias-label-primary));--lks-err-ink:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,var(--dsw-alias-label-primary))}
|
|
53
|
+
.lks-root,.lks-tv,.lks14{--lks-warn-ink:color-mix(in srgb,var(--dsw-alias-state-warn-label) 45%,var(--dsw-alias-label-primary));--lks-ok-ink:color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,var(--dsw-alias-label-primary));--lks-err-ink:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,var(--dsw-alias-label-primary))}
|
|
42
54
|
.lks-root{font-family:var(--dsw-font-family)}
|
|
43
55
|
.lks-root :where(button){font-family:var(--dsw-font-family);cursor:pointer;border:none;background:none;padding:0}
|
|
44
56
|
|
|
@@ -59,7 +71,7 @@ html[data-dsh-lookatstudy-active] .lks14-shell-view{display:flex;flex-direction:
|
|
|
59
71
|
html[data-dsh-lookatstudy-active] [data-pane='conversation'] > :not([data-dsh-lookatstudy-view]),
|
|
60
72
|
html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatstudy-view]){display:none !important}
|
|
61
73
|
[data-dsh-lookatstudy-view]{container-type:inline-size}
|
|
62
|
-
.lks14{
|
|
74
|
+
.lks14{position:relative;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
|
|
63
75
|
.lks14 button{font-family:var(--dsw-font-family);cursor:pointer}
|
|
64
76
|
.lks14-body{display:flex;flex:1;min-width:0;min-height:0}
|
|
65
77
|
.lks14-col{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
|
@@ -152,6 +164,153 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
152
164
|
.lks14-zone{margin-bottom:12px}
|
|
153
165
|
.lks14-zoneh{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
|
|
154
166
|
|
|
167
|
+
|
|
168
|
+
/* toast capsules (P6, upstream Toast port): top-center under the panel head,
|
|
169
|
+
container inert to pointers, items auto; exit is an animation handshake —
|
|
170
|
+
reduced-motion keeps a .01ms duration so the end event still fires. */
|
|
171
|
+
.lks-toasts{position:absolute;top:44px;left:50%;transform:translateX(-50%);z-index:40;display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;max-width:min(440px,90%)}
|
|
172
|
+
.lks-toast{pointer-events:auto;display:flex;align-items:center;gap:8px;max-width:100%;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:6px 8px 6px 14px;font-size:13px;line-height:1.4;box-shadow:0 6px 24px rgba(0,0,0,.14);animation:lks-toast-in .18s ease}
|
|
173
|
+
.lks-toast.exiting{animation:lks-toast-out .18s ease forwards}
|
|
174
|
+
.lks-toast .lks-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
175
|
+
.lks-toast .lks-toast-glyph{flex:none}
|
|
176
|
+
.lks-toast .lks-toast-glyph.ok{color:var(--lks-ok-ink)}
|
|
177
|
+
.lks-toast .lks-toast-glyph.warn{color:var(--lks-warn-ink)}
|
|
178
|
+
.lks-toast .lks-toast-glyph.err{color:var(--lks-err-ink)}
|
|
179
|
+
.lks-toast .lks-toast-glyph.info{color:var(--dsw-alias-state-business-primary)}
|
|
180
|
+
.lks-toast-action{border:none;background:none;color:var(--dsw-alias-state-business-primary);font-weight:600;font-size:13px;cursor:pointer;padding:4px 8px;border-radius:999px;flex:none}
|
|
181
|
+
.lks-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
182
|
+
.lks-toast-action:focus-visible,.lks-toast-close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
|
|
183
|
+
.lks-toast-close{border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;font-size:15px;line-height:1;padding:0;flex:none}
|
|
184
|
+
.lks-toast-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
|
|
185
|
+
@keyframes lks-toast-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
|
|
186
|
+
@keyframes lks-toast-out{to{opacity:0;transform:translateY(-6px)}}
|
|
187
|
+
@media (prefers-reduced-motion:reduce){.lks-toast{animation-duration:.01ms}}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
/* the companion creature (P7): bottom-right of the panel; gentle bob idle,
|
|
197
|
+
bounce while talking, pop on celebration; reduced-motion = static (a11y). */
|
|
198
|
+
.lks-companion{position:absolute;right:18px;bottom:14px;z-index:20;width:56px;height:56px;cursor:pointer;filter:drop-shadow(0 4px 10px rgba(0,0,0,.18));animation:lks-comp-bob 3.2s ease-in-out infinite}
|
|
199
|
+
.lks-companion:focus-visible{outline:none;filter:drop-shadow(0 0 0 2px var(--dsw-alias-state-business-primary)) drop-shadow(0 4px 10px rgba(0,0,0,.18))}
|
|
200
|
+
.lks-companion.mood-talking{animation:lks-comp-talk .6s ease-in-out infinite}
|
|
201
|
+
.lks-companion.mood-celebrating{animation:lks-comp-pop .5s cubic-bezier(.34,1.56,.64,1) 3}
|
|
202
|
+
.lks-companion.mood-encouraging{animation:lks-comp-nudge 1.2s ease-in-out 2}
|
|
203
|
+
@keyframes lks-comp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
|
204
|
+
@keyframes lks-comp-talk{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-7px) scale(1.06)}}
|
|
205
|
+
@keyframes lks-comp-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}
|
|
206
|
+
@keyframes lks-comp-nudge{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px) rotate(-4deg)}75%{transform:translateX(5px) rotate(4deg)}}
|
|
207
|
+
@media (prefers-reduced-motion:reduce){.lks-companion{animation:none}}
|
|
208
|
+
|
|
209
|
+
/* P5: proposal banner (ConfirmCard semantics) + thinking row + narrow switch */
|
|
210
|
+
.lks-propbanner{flex:none;display:flex;align-items:center;gap:8px;margin:8px 12px 0;padding:8px 12px;border-radius:10px;background:var(--dsw-alias-state-business-tertiary);border:1px solid var(--dsw-alias-border-l2);font-size:12.5px;color:var(--dsw-alias-label-primary-bluish)}
|
|
211
|
+
.lks-propbanner .lks-propbanner-why{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
212
|
+
.lks14-thinking{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--dsw-alias-label-secondary);padding:2px 2px}
|
|
213
|
+
.lks14-switch{display:none;flex-direction:row;gap:2px;align-self:center;margin:8px 0 0;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px}
|
|
214
|
+
.lks14-switch-btn{border-radius:999px;padding:4px 16px;font-size:13px;color:var(--dsw-alias-label-secondary);background:transparent}
|
|
215
|
+
.lks14-switch-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
216
|
+
.lks14-switch-btn.on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font-weight:600}
|
|
217
|
+
@container (max-width: 900px){
|
|
218
|
+
.lks14-switch{display:flex}
|
|
219
|
+
.lks14-body{flex-direction:column}
|
|
220
|
+
.lks14-rail{flex:none;max-height:32vh;border-right:none;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
221
|
+
.lks14-chat{min-width:0}
|
|
222
|
+
.lks14-note{flex:1 1 auto;min-height:0}
|
|
223
|
+
.lks14-body[data-pane='rail'] .lks14-chat,.lks14-body[data-pane='rail'] .lks14-note,
|
|
224
|
+
.lks14-body[data-pane='chat'] .lks14-rail,.lks14-body[data-pane='chat'] .lks14-note,
|
|
225
|
+
.lks14-body[data-pane='note'] .lks14-rail,.lks14-body[data-pane='note'] .lks14-chat{display:none}
|
|
226
|
+
.lks14-body[data-pane='rail'] .lks14-rail{display:flex;max-height:none;flex:1 1 auto}
|
|
227
|
+
.lks14-body[data-pane='note'] .lks14-note{display:flex}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* rail search results panel (P4, upstream CourseSearchPanel) */
|
|
231
|
+
.lks14-searchpanel{margin:4px 0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
|
|
232
|
+
.lks14-searchrow{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;text-align:left;font:inherit;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
233
|
+
.lks14-searchrow:last-child{border-bottom:none}
|
|
234
|
+
.lks14-searchrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
235
|
+
.lks14-searchrow-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
236
|
+
.lks14-searchrow-course{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
|
|
237
|
+
.lks14-searchrow-snip{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
238
|
+
|
|
239
|
+
/* review self-rating card (P3, upstream SelfRatingCard: three Memrise levels) */
|
|
240
|
+
.lks-ratecard{flex:none;display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-state-business-tertiary);padding:10px 12px;margin-bottom:8px}
|
|
241
|
+
.lks-ratecard-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary-bluish)}
|
|
242
|
+
.lks-ratecard-opts{display:flex;gap:6px}
|
|
243
|
+
.lks-ratecard-opt{flex:1;font:inherit;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:7px 10px;cursor:pointer}
|
|
244
|
+
.lks-ratecard-opt:hover{border-color:var(--dsw-alias-state-business-primary)}
|
|
245
|
+
.lks-ratecard-opt.again:hover{border-color:var(--dsw-alias-state-error-primary)}
|
|
246
|
+
.lks-ratecard-opt.best{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 86%,#000);color:#fff;border-color:transparent}
|
|
247
|
+
.lks-ratecard-opt.best:hover{filter:brightness(1.1)}
|
|
248
|
+
.lks-ratecard-opt:disabled{opacity:.6;cursor:default}
|
|
249
|
+
.lks14-dueitem{font:inherit;text-align:left}
|
|
250
|
+
|
|
251
|
+
/* selection-to-note (P2, upstream selection flow): the popover floats over
|
|
252
|
+
the prose; persisted highlights are soft amber marks. */
|
|
253
|
+
.lks14-prosewrap{position:relative}
|
|
254
|
+
.lks-quote-btn{position:absolute;z-index:12;display:flex;gap:2px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.16);transform:translateX(-50%)}
|
|
255
|
+
.lks-quote-btn button{border:none;background:none;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;font-weight:600;padding:6px 10px;cursor:pointer;border-radius:10px}
|
|
256
|
+
.lks-quote-btn button:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
257
|
+
.lks-quote-btn button + button{border-left:1px solid var(--dsw-alias-border-l1)}
|
|
258
|
+
mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border-bottom:2px solid var(--dsw-alias-state-warn-primary);padding:0 1px}
|
|
259
|
+
|
|
260
|
+
/* artifact cards (P1b, upstream artifacts port): chat column + notebook zones */
|
|
261
|
+
.lks-acard{flex:none;margin:2px 12px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:8px;overflow:hidden}
|
|
262
|
+
.lks-acard-head{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
|
|
263
|
+
.lks-acard-expand{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:2px 6px;border-radius:6px}
|
|
264
|
+
.lks-acard-expand:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
|
|
265
|
+
.lks-acard-table{border-collapse:collapse;display:block;max-width:100%;overflow-x:auto}
|
|
266
|
+
.lks-acard-table th,.lks-acard-table td{border:1px solid var(--dsw-alias-border-l2);padding:4px 9px;font-size:12.5px;text-align:left}
|
|
267
|
+
.lks-acard-table th{background:var(--dsw-alias-bg-layer-2)}
|
|
268
|
+
.lks-acard-code{background:var(--dsw-alias-markdown-code-block);border-radius:8px;padding:8px 0;overflow-x:auto;font-family:var(--dsw-font-markdown-code-block-small);font-size:12px;line-height:1.6;margin:0}
|
|
269
|
+
.lks-acard-line{display:block;white-space:pre}
|
|
270
|
+
.lks-acard-line i{display:inline-block;width:2.2em;color:var(--dsw-alias-label-tertiary);font-style:normal;text-align:right;padding-right:8px;user-select:none}
|
|
271
|
+
.lks-acard-notes{display:flex;flex-direction:column;gap:5px}
|
|
272
|
+
.lks-acard-note{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-business-primary);padding-left:8px}
|
|
273
|
+
.lks-acard-note b{color:var(--dsw-alias-label-primary);font-weight:600;margin-right:4px}
|
|
274
|
+
.lks-acard-diagram{overflow-x:auto;text-align:center}
|
|
275
|
+
.lks-acard-diagram svg{max-width:100%;height:auto}
|
|
276
|
+
.lks-acard-modal{position:fixed;inset:0;z-index:90;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:32px}
|
|
277
|
+
.lks-acard-modal-body{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;max-width:900px;max-height:100%;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}
|
|
278
|
+
.lks-acard-modal-title{display:flex;align-items:center;font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
279
|
+
.lks-acard-guess-prompt{font-size:13.5px;line-height:1.6;color:var(--dsw-alias-label-primary)}
|
|
280
|
+
.lks-acard-guess-opts{display:flex;gap:8px}
|
|
281
|
+
.lks-acard-guess-opt{flex:1;font:inherit;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;cursor:pointer}
|
|
282
|
+
.lks-acard-guess-opt:not(:disabled):hover{border-color:var(--dsw-alias-state-business-primary)}
|
|
283
|
+
.lks-acard-guess-opt.picked{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
|
|
284
|
+
.lks-acard-guess-wait{font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
285
|
+
.lks-acard-stage{margin-top:14px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
286
|
+
.lks-acard-stage .lks-acard{margin:0}
|
|
287
|
+
.lks14-zone .lks-acard{margin:0 0 8px}
|
|
288
|
+
/* notebook tab badge (unseen artifacts) */
|
|
289
|
+
.lks-viewtab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;border-radius:8px;background:var(--dsw-alias-state-business-primary);color:#fff;font-size:10px;font-weight:700;margin-left:5px;padding:0 4px}
|
|
290
|
+
|
|
291
|
+
/* practice card (P1, upstream QuizArtifact port): chat column, below the stream */
|
|
292
|
+
.lks-qcard{flex:none;margin:2px 12px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:8px}
|
|
293
|
+
.lks-qcard-head{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
|
|
294
|
+
.lks-qcard-count{margin-left:auto;font-weight:400;color:var(--dsw-alias-label-tertiary)}
|
|
295
|
+
.lks-qcard-q{display:flex;flex-direction:column;gap:6px}
|
|
296
|
+
.lks-qcard-prompt{font-size:13.5px;line-height:1.55;color:var(--dsw-alias-label-primary)}
|
|
297
|
+
.lks-qcard-opts{display:flex;flex-direction:column;gap:4px}
|
|
298
|
+
.lks-qcard-opt{text-align:left;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:6px 10px;cursor:pointer}
|
|
299
|
+
.lks-qcard-opt:not(:disabled):hover{border-color:var(--dsw-alias-state-business-primary)}
|
|
300
|
+
.lks-qcard-opt:disabled{cursor:default}
|
|
301
|
+
.lks-qcard-opt.right{border-color:var(--dsw-alias-state-success-primary);color:var(--lks-ok-ink)}
|
|
302
|
+
.lks-qcard-opt.wrong{border-color:var(--dsw-alias-state-error-primary);color:var(--lks-err-ink)}
|
|
303
|
+
.lks-qcard-opt.dim{opacity:.75}
|
|
304
|
+
.lks-qcard-expl{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-business-primary);padding-left:8px}
|
|
305
|
+
.lks-qcard-next{margin-top:2px;border:none;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font:inherit;font-size:12.5px;font-weight:600;border-radius:8px;padding:5px 14px;cursor:pointer}
|
|
306
|
+
.lks-qcard-next:hover{filter:brightness(1.1)}
|
|
307
|
+
.lks-qcard-review .lks-qcard-q.review{padding:6px 0;border-bottom:1px dashed var(--dsw-alias-border-l1)}
|
|
308
|
+
.lks-qcard-review .lks-qcard-q.review.wrong .lks-qcard-prompt{color:var(--lks-err-ink)}
|
|
309
|
+
.lks-qcard-ans{font-size:12.5px;color:var(--dsw-alias-label-secondary)}
|
|
310
|
+
.lks-qcard-actions{display:flex;flex-wrap:wrap;gap:6px}
|
|
311
|
+
.lks-qcard-action{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:12.5px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:4px 12px;cursor:pointer}
|
|
312
|
+
.lks-qcard-action:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
|
|
313
|
+
|
|
155
314
|
/* note cards (shared: panel notebook + settings-era shapes) */
|
|
156
315
|
.lks-note{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 14px;margin-bottom:8px}
|
|
157
316
|
.lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
|
|
@@ -346,6 +505,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
346
505
|
fill: "currentColor"
|
|
347
506
|
}));
|
|
348
507
|
}
|
|
508
|
+
/** IconWarningOutline16 */
|
|
509
|
+
function IconWarningOutline16({ size = 16, className }) {
|
|
510
|
+
return (0, react.createElement)("svg", {
|
|
511
|
+
width: size,
|
|
512
|
+
height: size,
|
|
513
|
+
className,
|
|
514
|
+
viewBox: "0 0 16 16",
|
|
515
|
+
fill: "none",
|
|
516
|
+
xmlns: "http://www.w3.org/2000/svg"
|
|
517
|
+
}, (0, react.createElement)("path", {
|
|
518
|
+
d: "M6.3002 3.32843L7.69986 3.32843L7.69986 7.79657H6.3002L6.3002 3.32843Z",
|
|
519
|
+
fill: "currentColor"
|
|
520
|
+
}), (0, react.createElement)("path", {
|
|
521
|
+
d: "M6.3002 9.01935H7.69986V10.6711H6.3002V9.01935Z",
|
|
522
|
+
fill: "currentColor"
|
|
523
|
+
}), (0, react.createElement)("path", {
|
|
524
|
+
d: "M12.6328 6.99976C12.6328 3.88874 10.111 1.36694 7 1.36694C3.88899 1.36695 1.3672 3.88875 1.36719 6.99976C1.36719 10.1108 3.88899 12.6326 7 12.6326C10.111 12.6326 12.6328 10.1108 12.6328 6.99976ZM13.8582 6.99976C13.8582 10.7873 10.7876 13.8579 7 13.8579C3.21244 13.8579 0.141846 10.7873 0.141846 6.99976C0.141857 3.2122 3.21245 0.141612 7 0.141602C10.7876 0.141602 13.8581 3.21219 13.8582 6.99976Z",
|
|
525
|
+
fill: "currentColor"
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
349
528
|
/**
|
|
350
529
|
* Plugin-original companions for learning-domain glyphs the host set does not
|
|
351
530
|
* carry (lock/star/book/crown/bolt/power/pin/flame) — authored here in the
|
|
@@ -556,6 +735,30 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
556
735
|
});
|
|
557
736
|
this.refresh();
|
|
558
737
|
}
|
|
738
|
+
/** Save a learner selection as a record-zone note (the highlight anchor is the quote). */
|
|
739
|
+
async addUserNote(lessonId, quote) {
|
|
740
|
+
await fetchJson("/lookatstudy/api/note/user", {
|
|
741
|
+
method: "POST",
|
|
742
|
+
headers: { "content-type": "application/json" },
|
|
743
|
+
body: JSON.stringify({
|
|
744
|
+
lessonId,
|
|
745
|
+
quote
|
|
746
|
+
})
|
|
747
|
+
});
|
|
748
|
+
this.refresh();
|
|
749
|
+
}
|
|
750
|
+
/** Record one SM-2 self-rating (1 again / 4 remembered / 5 mastered). */
|
|
751
|
+
async recordReview(lessonId, quality) {
|
|
752
|
+
await fetchJson("/lookatstudy/api/review", {
|
|
753
|
+
method: "POST",
|
|
754
|
+
headers: { "content-type": "application/json" },
|
|
755
|
+
body: JSON.stringify({
|
|
756
|
+
lessonId,
|
|
757
|
+
quality
|
|
758
|
+
})
|
|
759
|
+
});
|
|
760
|
+
this.refresh();
|
|
761
|
+
}
|
|
559
762
|
/** Delete one notebook entry from a lesson's Cornell zones. */
|
|
560
763
|
async deleteNote(lessonId, noteId) {
|
|
561
764
|
await fetchJson("/lookatstudy/api/note/delete", {
|
|
@@ -643,6 +846,8 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
643
846
|
searchLessons: studyStore.searchLessons.bind(studyStore),
|
|
644
847
|
deleteCourse: studyStore.deleteCourse.bind(studyStore),
|
|
645
848
|
deleteNote: studyStore.deleteNote.bind(studyStore),
|
|
849
|
+
addUserNote: studyStore.addUserNote.bind(studyStore),
|
|
850
|
+
recordReview: studyStore.recordReview.bind(studyStore),
|
|
646
851
|
bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
|
|
647
852
|
};
|
|
648
853
|
}
|
|
@@ -822,6 +1027,12 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
822
1027
|
}
|
|
823
1028
|
//#endregion
|
|
824
1029
|
//#region src/client/enhance.ts
|
|
1030
|
+
var enhance_exports = /* @__PURE__ */ __exportAll({
|
|
1031
|
+
enhanceCode: () => enhanceCode,
|
|
1032
|
+
enhanceDiagrams: () => enhanceDiagrams,
|
|
1033
|
+
enhanceMath: () => enhanceMath,
|
|
1034
|
+
enhanceRendered: () => enhanceRendered
|
|
1035
|
+
});
|
|
825
1036
|
/**
|
|
826
1037
|
* Post-render blackboard enhancement (SPEC Phase 2): math (KaTeX), code
|
|
827
1038
|
* highlighting (shiki), and mermaid diagrams — every dependency loaded from a
|
|
@@ -1470,6 +1681,68 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
1470
1681
|
"rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
|
|
1471
1682
|
"rail.empty.button": "导入",
|
|
1472
1683
|
"composer.send": "发送",
|
|
1684
|
+
"quiz.card.title": "练习",
|
|
1685
|
+
"quiz.progress": "{cur}/{total}",
|
|
1686
|
+
"quiz.next": "下一题",
|
|
1687
|
+
"quiz.finish": "看成绩单",
|
|
1688
|
+
"quiz.score": "成绩:{correct}/{total}",
|
|
1689
|
+
"quiz.right": "答对了",
|
|
1690
|
+
"quiz.wrong": "答错了",
|
|
1691
|
+
"quiz.youChose": "你选了",
|
|
1692
|
+
"quiz.answer": "正确答案",
|
|
1693
|
+
"quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
|
|
1694
|
+
"quiz.action.explain-wrong": "讲讲错题",
|
|
1695
|
+
"quiz.action.retry": "再来一组",
|
|
1696
|
+
"quiz.action.go-deeper": "深入原理",
|
|
1697
|
+
"quiz.action.mark-mastered": "标记我掌握了",
|
|
1698
|
+
"quiz.action.markMastered.hint": "会让导师出综合题检验,通过即标记掌握",
|
|
1699
|
+
"quiz.action.next-topic": "下一个知识点",
|
|
1700
|
+
"artifact.lines": "第 {from}-{to} 行:",
|
|
1701
|
+
"artifact.expand": "放大查看",
|
|
1702
|
+
"artifact.guess.title": "猜一猜",
|
|
1703
|
+
"artifact.guess.pick": "我选「{label}」",
|
|
1704
|
+
"artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
|
|
1705
|
+
"artifact.sedimented": "有新的学习产物沉淀进笔记",
|
|
1706
|
+
"zone.artifacts": "AI 产物",
|
|
1707
|
+
"note.quote.ask": "提问这段",
|
|
1708
|
+
"note.quote.save": "加到笔记",
|
|
1709
|
+
"note.quote.template": "请讲解这段:「{text}」",
|
|
1710
|
+
"note.saved": "已存入记录区笔记",
|
|
1711
|
+
"review.rate.title": "复习自评——这课现在记得多牢?",
|
|
1712
|
+
"review.again": "再来一次",
|
|
1713
|
+
"review.remembered": "记住了",
|
|
1714
|
+
"review.mastered": "完全掌握",
|
|
1715
|
+
"review.done.good": "已安排下次复习",
|
|
1716
|
+
"review.done.again": "明天再来一遍",
|
|
1717
|
+
"review.jump": "点开这一课去复习",
|
|
1718
|
+
"review.nudge": "有 {n} 课到了复习时间,先回来练一遍?",
|
|
1719
|
+
"review.nudge.go": "去复习",
|
|
1720
|
+
"rail.search.jump": "打开这一课",
|
|
1721
|
+
"proposal.banner": "《{lesson}》可以提前毕业了",
|
|
1722
|
+
"proposal.accept": "接受",
|
|
1723
|
+
"proposal.decline": "再练练",
|
|
1724
|
+
"proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
|
|
1725
|
+
"proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
|
|
1726
|
+
"tutor.thinking": "导师思考中…",
|
|
1727
|
+
"pane.rail": "课程",
|
|
1728
|
+
"pane.chat": "对话",
|
|
1729
|
+
"pane.note": "黑板",
|
|
1730
|
+
"companion.poke": "戳一戳伴学伙伴",
|
|
1731
|
+
"companion.poked": "伴学伙伴向你眨了眨眼",
|
|
1732
|
+
"companion.form.ember": "小焰",
|
|
1733
|
+
"companion.form.frost": "霜绒",
|
|
1734
|
+
"companion.form.moss": "苔芽",
|
|
1735
|
+
"companion.form.star": "星尘",
|
|
1736
|
+
"companion.form.ink": "墨墨",
|
|
1737
|
+
"settings.companion": "伴学伙伴",
|
|
1738
|
+
"settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
|
|
1739
|
+
"quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
|
|
1740
|
+
"quiz.msg.retry": "再来一组类似的题巩固一下。",
|
|
1741
|
+
"quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
|
|
1742
|
+
"quiz.msg.mark-mastered": "这课我觉得掌握了,帮我确认一下——出个综合题检验,通过了就标记为掌握。",
|
|
1743
|
+
"quiz.msg.next-topic": "进入下一个知识点。",
|
|
1744
|
+
"toast.region": "通知",
|
|
1745
|
+
"toast.close": "关闭",
|
|
1473
1746
|
"composer.busy": "导师正在回复…",
|
|
1474
1747
|
"rail.empty.demo": "导入示例课程",
|
|
1475
1748
|
"rail.mastered": "毕业 {mastered}/{total} 课",
|
|
@@ -1608,6 +1881,68 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
1608
1881
|
"rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
|
|
1609
1882
|
"rail.empty.button": "Import",
|
|
1610
1883
|
"composer.send": "Send",
|
|
1884
|
+
"quiz.card.title": "Practice",
|
|
1885
|
+
"quiz.progress": "{cur}/{total}",
|
|
1886
|
+
"quiz.next": "Next",
|
|
1887
|
+
"quiz.finish": "See score",
|
|
1888
|
+
"quiz.score": "Score: {correct}/{total}",
|
|
1889
|
+
"quiz.right": "Correct",
|
|
1890
|
+
"quiz.wrong": "Wrong",
|
|
1891
|
+
"quiz.youChose": "You chose",
|
|
1892
|
+
"quiz.answer": "Answer",
|
|
1893
|
+
"quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
|
|
1894
|
+
"quiz.action.explain-wrong": "Explain my wrong answers",
|
|
1895
|
+
"quiz.action.retry": "Another set",
|
|
1896
|
+
"quiz.action.go-deeper": "Go deeper",
|
|
1897
|
+
"quiz.action.mark-mastered": "Mark me as mastered",
|
|
1898
|
+
"quiz.action.markMastered.hint": "Asks the tutor to verify with a final question, then marks mastery",
|
|
1899
|
+
"quiz.action.next-topic": "Next topic",
|
|
1900
|
+
"artifact.lines": "Lines {from}-{to}: ",
|
|
1901
|
+
"artifact.expand": "Expand",
|
|
1902
|
+
"artifact.guess.title": "Take a guess",
|
|
1903
|
+
"artifact.guess.pick": "I pick \"{label}\"",
|
|
1904
|
+
"artifact.guess.wait": "Your instinct is noted — the tutor reveals next turn",
|
|
1905
|
+
"artifact.sedimented": "A new artifact settled into the notebook",
|
|
1906
|
+
"zone.artifacts": "AI artifacts",
|
|
1907
|
+
"note.quote.ask": "Ask about this",
|
|
1908
|
+
"note.quote.save": "Add note",
|
|
1909
|
+
"note.quote.template": "Please explain this passage: \"{text}\"",
|
|
1910
|
+
"note.saved": "Saved to the record zone",
|
|
1911
|
+
"review.rate.title": "Review self-check — how well do you remember?",
|
|
1912
|
+
"review.again": "Again",
|
|
1913
|
+
"review.remembered": "Remembered",
|
|
1914
|
+
"review.mastered": "Mastered",
|
|
1915
|
+
"review.done.good": "Next review scheduled",
|
|
1916
|
+
"review.done.again": "Back again tomorrow",
|
|
1917
|
+
"review.jump": "Open this lesson to review",
|
|
1918
|
+
"review.nudge": "{n} lessons are due for review — come practice first?",
|
|
1919
|
+
"review.nudge.go": "Review now",
|
|
1920
|
+
"rail.search.jump": "Open this lesson",
|
|
1921
|
+
"proposal.banner": "{lesson} is ready to graduate early",
|
|
1922
|
+
"proposal.accept": "Accept",
|
|
1923
|
+
"proposal.decline": "Keep practicing",
|
|
1924
|
+
"proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
|
|
1925
|
+
"proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
|
|
1926
|
+
"tutor.thinking": "The tutor is thinking…",
|
|
1927
|
+
"pane.rail": "Course",
|
|
1928
|
+
"pane.chat": "Chat",
|
|
1929
|
+
"pane.note": "Notebook",
|
|
1930
|
+
"companion.poke": "Poke the companion",
|
|
1931
|
+
"companion.poked": "The companion winks at you",
|
|
1932
|
+
"companion.form.ember": "Ember",
|
|
1933
|
+
"companion.form.frost": "Frost",
|
|
1934
|
+
"companion.form.moss": "Moss",
|
|
1935
|
+
"companion.form.star": "Stardust",
|
|
1936
|
+
"companion.form.ink": "Ink",
|
|
1937
|
+
"settings.companion": "Companion",
|
|
1938
|
+
"settings.companion.hint": "Choose the companion creature in the study panel corner.",
|
|
1939
|
+
"quiz.msg.explain-wrong": "I got some questions wrong — walk me through why and the right line of thinking.",
|
|
1940
|
+
"quiz.msg.retry": "Give me another similar set to consolidate.",
|
|
1941
|
+
"quiz.msg.go-deeper": "I did well on this set — go deeper into the principles and common confusions.",
|
|
1942
|
+
"quiz.msg.mark-mastered": "I feel I have mastered this lesson — verify me with a final question and mark it mastered if I pass.",
|
|
1943
|
+
"quiz.msg.next-topic": "Move on to the next topic.",
|
|
1944
|
+
"toast.region": "Notifications",
|
|
1945
|
+
"toast.close": "Close",
|
|
1611
1946
|
"composer.busy": "The tutor is replying…",
|
|
1612
1947
|
"rail.empty.demo": "Import the demo course",
|
|
1613
1948
|
"rail.mastered": "{mastered}/{total} lessons graduated",
|
|
@@ -2210,6 +2545,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2210
2545
|
if (event === void 0) continue;
|
|
2211
2546
|
if (entry.type === "event") {
|
|
2212
2547
|
if (event.type === "user/message") {
|
|
2548
|
+
if (event.data?.source?.kind !== "user") continue;
|
|
2213
2549
|
const text = userText(event);
|
|
2214
2550
|
if (text !== "") rows.push({
|
|
2215
2551
|
key: `u${event.seq}`,
|
|
@@ -2355,6 +2691,782 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2355
2691
|
}
|
|
2356
2692
|
};
|
|
2357
2693
|
//#endregion
|
|
2694
|
+
//#region src/client/toast.ts
|
|
2695
|
+
/** Per-severity defaults (upstream Toast.tsx): error 6s, warning 5s, rest 4s. */
|
|
2696
|
+
const SEVERITY_DURATION = {
|
|
2697
|
+
default: 4e3,
|
|
2698
|
+
success: 4e3,
|
|
2699
|
+
info: 4e3,
|
|
2700
|
+
warning: 5e3,
|
|
2701
|
+
error: 6e3
|
|
2702
|
+
};
|
|
2703
|
+
const browserScheduler = (fn, ms) => {
|
|
2704
|
+
const handle = window.setTimeout(fn, ms);
|
|
2705
|
+
return () => {
|
|
2706
|
+
window.clearTimeout(handle);
|
|
2707
|
+
};
|
|
2708
|
+
};
|
|
2709
|
+
var ToastStore = class {
|
|
2710
|
+
items = [];
|
|
2711
|
+
nextId = 0;
|
|
2712
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2713
|
+
cancellers = /* @__PURE__ */ new Map();
|
|
2714
|
+
scheduler;
|
|
2715
|
+
constructor(scheduler = browserScheduler) {
|
|
2716
|
+
this.scheduler = scheduler;
|
|
2717
|
+
}
|
|
2718
|
+
getSnapshot() {
|
|
2719
|
+
return this.items;
|
|
2720
|
+
}
|
|
2721
|
+
subscribe(listener) {
|
|
2722
|
+
this.listeners.add(listener);
|
|
2723
|
+
return () => {
|
|
2724
|
+
this.listeners.delete(listener);
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
emit() {
|
|
2728
|
+
for (const listener of this.listeners) listener();
|
|
2729
|
+
}
|
|
2730
|
+
/** Enqueue one toast; its auto-exit is scheduled at its duration. */
|
|
2731
|
+
show(message, opts) {
|
|
2732
|
+
const severity = opts?.severity ?? "default";
|
|
2733
|
+
const id = ++this.nextId;
|
|
2734
|
+
const item = {
|
|
2735
|
+
id,
|
|
2736
|
+
message,
|
|
2737
|
+
severity,
|
|
2738
|
+
duration: opts?.duration ?? SEVERITY_DURATION[severity],
|
|
2739
|
+
action: opts?.action,
|
|
2740
|
+
exiting: false
|
|
2741
|
+
};
|
|
2742
|
+
this.items = [...this.items, item];
|
|
2743
|
+
this.cancellers.set(id, this.scheduler(() => this.startExit(id), item.duration));
|
|
2744
|
+
this.emit();
|
|
2745
|
+
return id;
|
|
2746
|
+
}
|
|
2747
|
+
/** Begin the exit (dismiss click or the duration timer); cancels the timer. */
|
|
2748
|
+
startExit(id) {
|
|
2749
|
+
const cancel = this.cancellers.get(id);
|
|
2750
|
+
if (cancel !== void 0) {
|
|
2751
|
+
cancel();
|
|
2752
|
+
this.cancellers.delete(id);
|
|
2753
|
+
}
|
|
2754
|
+
this.items = this.items.map((t) => t.id === id ? {
|
|
2755
|
+
...t,
|
|
2756
|
+
exiting: true
|
|
2757
|
+
} : t);
|
|
2758
|
+
this.emit();
|
|
2759
|
+
}
|
|
2760
|
+
/** Actually remove — called by the view when the exit animation ends. */
|
|
2761
|
+
finish(id) {
|
|
2762
|
+
this.items = this.items.filter((t) => t.id !== id);
|
|
2763
|
+
this.cancellers.delete(id);
|
|
2764
|
+
this.emit();
|
|
2765
|
+
}
|
|
2766
|
+
/** Drop everything now (the panel unmounted — nothing outlives the panel). */
|
|
2767
|
+
clear() {
|
|
2768
|
+
for (const cancel of this.cancellers.values()) cancel();
|
|
2769
|
+
this.cancellers.clear();
|
|
2770
|
+
this.items = [];
|
|
2771
|
+
this.emit();
|
|
2772
|
+
}
|
|
2773
|
+
};
|
|
2774
|
+
/** The panel-wide singleton stack; consumers call showStudyToast. */
|
|
2775
|
+
const toastStore = new ToastStore();
|
|
2776
|
+
function showStudyToast(message, opts) {
|
|
2777
|
+
return toastStore.show(message, opts);
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* The exits after one finished card — never fewer than two (the anti-dead-end
|
|
2781
|
+
* promise). Wrong answers → patch the hole first; perfect + near-mastered →
|
|
2782
|
+
* offer the mastery exit; perfect + already graduated → move on.
|
|
2783
|
+
* Pure port of upstream getPostQuizActions.
|
|
2784
|
+
*/
|
|
2785
|
+
function getPostQuizActions(score, mastery) {
|
|
2786
|
+
const { correct, total } = score;
|
|
2787
|
+
const allCorrect = total > 0 && correct === total;
|
|
2788
|
+
const hasWrong = total > 0 && correct < total;
|
|
2789
|
+
const alreadyMastered = mastery !== null && mastery >= .9;
|
|
2790
|
+
const nearMastered = mastery !== null && mastery >= .85;
|
|
2791
|
+
if (hasWrong) return [{
|
|
2792
|
+
id: "explain-wrong",
|
|
2793
|
+
advancesMastery: false
|
|
2794
|
+
}, {
|
|
2795
|
+
id: "retry",
|
|
2796
|
+
advancesMastery: false
|
|
2797
|
+
}];
|
|
2798
|
+
if (allCorrect && alreadyMastered) return [{
|
|
2799
|
+
id: "next-topic",
|
|
2800
|
+
advancesMastery: false
|
|
2801
|
+
}, {
|
|
2802
|
+
id: "go-deeper",
|
|
2803
|
+
advancesMastery: false
|
|
2804
|
+
}];
|
|
2805
|
+
if (allCorrect && nearMastered) return [{
|
|
2806
|
+
id: "mark-mastered",
|
|
2807
|
+
advancesMastery: true
|
|
2808
|
+
}, {
|
|
2809
|
+
id: "go-deeper",
|
|
2810
|
+
advancesMastery: false
|
|
2811
|
+
}];
|
|
2812
|
+
if (allCorrect) return [{
|
|
2813
|
+
id: "go-deeper",
|
|
2814
|
+
advancesMastery: false
|
|
2815
|
+
}, {
|
|
2816
|
+
id: "retry",
|
|
2817
|
+
advancesMastery: false
|
|
2818
|
+
}];
|
|
2819
|
+
return [{
|
|
2820
|
+
id: "explain-wrong",
|
|
2821
|
+
advancesMastery: false
|
|
2822
|
+
}, {
|
|
2823
|
+
id: "retry",
|
|
2824
|
+
advancesMastery: false
|
|
2825
|
+
}];
|
|
2826
|
+
}
|
|
2827
|
+
/** localStorage key (upstream quizProgressKey semantics: per artifact). */
|
|
2828
|
+
function quizProgressKey(lessonId, artifactId) {
|
|
2829
|
+
return `dsh-plugin-lookatstudy:quiz:${lessonId}:${artifactId}`;
|
|
2830
|
+
}
|
|
2831
|
+
function loadQuizProgress(lessonId, artifactId, questionCount, storage = localStorage) {
|
|
2832
|
+
try {
|
|
2833
|
+
const raw = storage.getItem(quizProgressKey(lessonId, artifactId));
|
|
2834
|
+
if (raw === null) return {
|
|
2835
|
+
answers: Array(questionCount).fill(-1),
|
|
2836
|
+
done: false
|
|
2837
|
+
};
|
|
2838
|
+
const parsed = JSON.parse(raw);
|
|
2839
|
+
const answers = Array.isArray(parsed.answers) ? parsed.answers.map((a) => typeof a === "number" ? a : -1) : [];
|
|
2840
|
+
const padded = Array(questionCount).fill(-1);
|
|
2841
|
+
for (let i = 0; i < Math.min(answers.length, questionCount); i += 1) padded[i] = answers[i];
|
|
2842
|
+
return {
|
|
2843
|
+
answers: padded,
|
|
2844
|
+
done: parsed.done === true
|
|
2845
|
+
};
|
|
2846
|
+
} catch {
|
|
2847
|
+
return {
|
|
2848
|
+
answers: Array(questionCount).fill(-1),
|
|
2849
|
+
done: false
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
function saveQuizProgress(lessonId, artifactId, progress, storage = localStorage) {
|
|
2854
|
+
try {
|
|
2855
|
+
storage.setItem(quizProgressKey(lessonId, artifactId), JSON.stringify(progress));
|
|
2856
|
+
} catch {}
|
|
2857
|
+
}
|
|
2858
|
+
/** The score over recorded answers (pure). */
|
|
2859
|
+
function quizScore(data, answers) {
|
|
2860
|
+
let correct = 0;
|
|
2861
|
+
let total = 0;
|
|
2862
|
+
data.questions.forEach((q, i) => {
|
|
2863
|
+
const choice = answers[i];
|
|
2864
|
+
if (choice === void 0 || choice < 0) return;
|
|
2865
|
+
total += 1;
|
|
2866
|
+
if (choice === q.answer) correct += 1;
|
|
2867
|
+
});
|
|
2868
|
+
return {
|
|
2869
|
+
correct,
|
|
2870
|
+
total
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
const ACTION_ICONS = {
|
|
2874
|
+
"explain-wrong": IconBookFill16,
|
|
2875
|
+
"retry": IconRefreshOutline16,
|
|
2876
|
+
"go-deeper": IconBoltFill16,
|
|
2877
|
+
"mark-mastered": IconCrownFill16,
|
|
2878
|
+
"next-topic": IconStarFill16
|
|
2879
|
+
};
|
|
2880
|
+
/**
|
|
2881
|
+
* The card: one question at a time, click-to-answer (instant local judge +
|
|
2882
|
+
* explanation reveal — the cursor is explicit, so the reveal is never skipped),
|
|
2883
|
+
* progress persisted per artifact; the score screen is an explicit step, and
|
|
2884
|
+
* on first arrival its completion hook goes to the tutor exactly once.
|
|
2885
|
+
*/
|
|
2886
|
+
function QuizCard({ lessonId, artifactId, data, masteryPct, send, onFinished }) {
|
|
2887
|
+
const questions = data.questions;
|
|
2888
|
+
const [progress, setProgress] = (0, react.useState)(() => loadQuizProgress(lessonId, artifactId, questions.length));
|
|
2889
|
+
const [cursor, setCursor] = (0, react.useState)(0);
|
|
2890
|
+
const [showScore, setShowScore] = (0, react.useState)(false);
|
|
2891
|
+
const [hookSent, setHookSent] = (0, react.useState)(false);
|
|
2892
|
+
(0, react.useEffect)(() => {
|
|
2893
|
+
const next = loadQuizProgress(lessonId, artifactId, questions.length);
|
|
2894
|
+
setProgress(next);
|
|
2895
|
+
setShowScore(next.done);
|
|
2896
|
+
const firstUnanswered = next.answers.findIndex((a) => a < 0);
|
|
2897
|
+
setCursor(firstUnanswered === -1 ? questions.length - 1 : firstUnanswered);
|
|
2898
|
+
setHookSent(false);
|
|
2899
|
+
}, [
|
|
2900
|
+
lessonId,
|
|
2901
|
+
artifactId,
|
|
2902
|
+
questions.length
|
|
2903
|
+
]);
|
|
2904
|
+
const allAnswered = progress.answers.every((a) => a >= 0);
|
|
2905
|
+
const score = quizScore(data, progress.answers);
|
|
2906
|
+
const mastery = masteryPct === null ? null : masteryPct / 100;
|
|
2907
|
+
(0, react.useEffect)(() => {
|
|
2908
|
+
if (!showScore || !allAnswered || hookSent) return;
|
|
2909
|
+
setHookSent(true);
|
|
2910
|
+
onFinished?.(score.correct === score.total);
|
|
2911
|
+
const next = {
|
|
2912
|
+
answers: [...progress.answers],
|
|
2913
|
+
done: true
|
|
2914
|
+
};
|
|
2915
|
+
setProgress(next);
|
|
2916
|
+
saveQuizProgress(lessonId, artifactId, next);
|
|
2917
|
+
send(tr("quiz.hook", {
|
|
2918
|
+
correct: score.correct,
|
|
2919
|
+
total: score.total
|
|
2920
|
+
}));
|
|
2921
|
+
}, [
|
|
2922
|
+
showScore,
|
|
2923
|
+
allAnswered,
|
|
2924
|
+
hookSent,
|
|
2925
|
+
lessonId,
|
|
2926
|
+
artifactId,
|
|
2927
|
+
progress.answers,
|
|
2928
|
+
score.correct,
|
|
2929
|
+
score.total,
|
|
2930
|
+
send
|
|
2931
|
+
]);
|
|
2932
|
+
const answer = (choice) => {
|
|
2933
|
+
const next = {
|
|
2934
|
+
answers: [...progress.answers],
|
|
2935
|
+
done: progress.done
|
|
2936
|
+
};
|
|
2937
|
+
next.answers[cursor] = choice;
|
|
2938
|
+
setProgress(next);
|
|
2939
|
+
saveQuizProgress(lessonId, artifactId, next);
|
|
2940
|
+
};
|
|
2941
|
+
if (showScore && allAnswered) {
|
|
2942
|
+
const actions = getPostQuizActions(score, mastery);
|
|
2943
|
+
return (0, react.createElement)("div", {
|
|
2944
|
+
className: "lks-qcard",
|
|
2945
|
+
"data-lks-quiz": artifactId
|
|
2946
|
+
}, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconStarFill16, { size: 14 }), tr("quiz.score", {
|
|
2947
|
+
correct: score.correct,
|
|
2948
|
+
total: score.total
|
|
2949
|
+
})), (0, react.createElement)("div", { className: "lks-qcard-review" }, ...questions.map((q, i) => {
|
|
2950
|
+
const choice = progress.answers[i] ?? -1;
|
|
2951
|
+
const right = choice === q.answer;
|
|
2952
|
+
return (0, react.createElement)("div", {
|
|
2953
|
+
key: i,
|
|
2954
|
+
className: `lks-qcard-q review${right ? "" : " wrong"}`
|
|
2955
|
+
}, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, `${i + 1}. ${q.prompt}`), (0, react.createElement)("div", { className: "lks-qcard-ans" }, right ? "" : `${tr("quiz.youChose")}:${q.options[choice] ?? "—"} · `, `${tr("quiz.answer")}:${q.options[q.answer] ?? "—"}`), (0, react.createElement)("div", { className: "lks-qcard-expl" }, q.explanation));
|
|
2956
|
+
})), (0, react.createElement)("div", { className: "lks-qcard-actions" }, ...actions.map((a) => (0, react.createElement)("button", {
|
|
2957
|
+
key: a.id,
|
|
2958
|
+
className: "lks-qcard-action",
|
|
2959
|
+
title: a.advancesMastery ? tr("quiz.action.markMastered.hint") : void 0,
|
|
2960
|
+
onClick: () => {
|
|
2961
|
+
send(tr(`quiz.msg.${a.id}`));
|
|
2962
|
+
}
|
|
2963
|
+
}, (0, react.createElement)(ACTION_ICONS[a.id], { size: 13 }), tr(`quiz.action.${a.id}`)))));
|
|
2964
|
+
}
|
|
2965
|
+
const q = questions[cursor];
|
|
2966
|
+
const chosen = progress.answers[cursor] ?? -1;
|
|
2967
|
+
const options = q.options.map((opt, i) => {
|
|
2968
|
+
const picked = chosen === i;
|
|
2969
|
+
const isRight = i === q.answer;
|
|
2970
|
+
return (0, react.createElement)("button", {
|
|
2971
|
+
key: i,
|
|
2972
|
+
className: `lks-qcard-opt${chosen < 0 ? "" : picked ? isRight ? " right" : " wrong" : isRight ? " right dim" : ""}`,
|
|
2973
|
+
disabled: chosen >= 0,
|
|
2974
|
+
onClick: () => {
|
|
2975
|
+
answer(i);
|
|
2976
|
+
}
|
|
2977
|
+
}, opt);
|
|
2978
|
+
});
|
|
2979
|
+
const explanation = chosen < 0 ? null : (0, react.createElement)("div", { className: "lks-qcard-expl" }, `${chosen === q.answer ? tr("quiz.right") : tr("quiz.wrong")} — ${q.explanation}`, (0, react.createElement)("div", { style: { marginTop: "6px" } }, (0, react.createElement)("button", {
|
|
2980
|
+
className: "lks-qcard-next",
|
|
2981
|
+
onClick: () => {
|
|
2982
|
+
if (cursor + 1 < questions.length) setCursor(cursor + 1);
|
|
2983
|
+
else setShowScore(true);
|
|
2984
|
+
}
|
|
2985
|
+
}, cursor + 1 < questions.length ? tr("quiz.next") : tr("quiz.finish"))));
|
|
2986
|
+
return (0, react.createElement)("div", {
|
|
2987
|
+
className: "lks-qcard",
|
|
2988
|
+
"data-lks-quiz": artifactId
|
|
2989
|
+
}, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconBookFill16, { size: 14 }), data.title ?? tr("quiz.card.title"), (0, react.createElement)("span", { className: "lks-qcard-count" }, tr("quiz.progress", {
|
|
2990
|
+
cur: cursor + 1,
|
|
2991
|
+
total: questions.length
|
|
2992
|
+
}))), (0, react.createElement)("div", { className: "lks-qcard-q" }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, q.prompt), (0, react.createElement)("div", { className: "lks-qcard-opts" }, ...options), explanation));
|
|
2993
|
+
}
|
|
2994
|
+
//#endregion
|
|
2995
|
+
//#region src/client/artifact-cards.tsx
|
|
2996
|
+
/**
|
|
2997
|
+
* The artifact cards (0.15.0 P1b, upstream artifacts port): compare tables,
|
|
2998
|
+
* code walkthroughs, mermaid diagrams (with the expand modal), and the
|
|
2999
|
+
* opening guess — plus the seen-tracking that feeds the notebook badge and
|
|
3000
|
+
* the "new artifact" toast. Rendering is lazy where it is heavy (mermaid via
|
|
3001
|
+
* the enhance pipeline); everything else is plain dsw-token DOM.
|
|
3002
|
+
* @module dsh-plugin-lookatstudy/client/artifact-cards
|
|
3003
|
+
*/
|
|
3004
|
+
/** localStorage key for the artifact ids the learner has already seen. */
|
|
3005
|
+
function seenArtifactsKey(lessonId) {
|
|
3006
|
+
return `dsh-plugin-lookatstudy:seen-artifacts:${lessonId}`;
|
|
3007
|
+
}
|
|
3008
|
+
/** Which artifact ids are new (not yet seen); pure over the stored set. */
|
|
3009
|
+
function unseenArtifacts(lessonId, artifacts, storage = localStorage) {
|
|
3010
|
+
let seen;
|
|
3011
|
+
try {
|
|
3012
|
+
const raw = storage.getItem(seenArtifactsKey(lessonId));
|
|
3013
|
+
seen = new Set(Array.isArray(JSON.parse(raw ?? "[]")) ? JSON.parse(raw ?? "[]") : []);
|
|
3014
|
+
} catch {
|
|
3015
|
+
seen = /* @__PURE__ */ new Set();
|
|
3016
|
+
}
|
|
3017
|
+
return artifacts.filter((a) => !seen.has(a.id)).map((a) => a.id);
|
|
3018
|
+
}
|
|
3019
|
+
/** Mark artifact ids seen (opening the notebook counts as seeing them). */
|
|
3020
|
+
function markArtifactsSeen(lessonId, ids, storage = localStorage) {
|
|
3021
|
+
try {
|
|
3022
|
+
let seen = [];
|
|
3023
|
+
try {
|
|
3024
|
+
const parsed = JSON.parse(storage.getItem(seenArtifactsKey(lessonId)) ?? "[]");
|
|
3025
|
+
if (Array.isArray(parsed)) seen = parsed.filter((x) => typeof x === "string");
|
|
3026
|
+
} catch {}
|
|
3027
|
+
storage.setItem(seenArtifactsKey(lessonId), JSON.stringify([.../* @__PURE__ */ new Set([...seen, ...ids])]));
|
|
3028
|
+
} catch {}
|
|
3029
|
+
}
|
|
3030
|
+
/** The cards render mermaid through the shared CDN pipeline
|
|
3031
|
+
* (enhanceDiagrams consumes pre > code.lang-mermaid blocks). */
|
|
3032
|
+
async function renderMermaidInto(el, code, onError) {
|
|
3033
|
+
try {
|
|
3034
|
+
const { enhanceDiagrams } = await Promise.resolve().then(() => enhance_exports);
|
|
3035
|
+
el.textContent = "";
|
|
3036
|
+
const pre = document.createElement("pre");
|
|
3037
|
+
const codeEl = document.createElement("code");
|
|
3038
|
+
codeEl.className = "lang-mermaid";
|
|
3039
|
+
codeEl.textContent = code;
|
|
3040
|
+
pre.appendChild(codeEl);
|
|
3041
|
+
el.appendChild(pre);
|
|
3042
|
+
if (await enhanceDiagrams(el) === 0) onError();
|
|
3043
|
+
} catch {
|
|
3044
|
+
onError();
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
/** Compare table card (headers + rows). */
|
|
3048
|
+
function CompareTableCard({ artifact }) {
|
|
3049
|
+
const headers = artifact.data.headers ?? [];
|
|
3050
|
+
const rows = artifact.data.rows ?? [];
|
|
3051
|
+
return (0, react.createElement)("div", {
|
|
3052
|
+
className: "lks-acard",
|
|
3053
|
+
"data-lks-artifact": artifact.id
|
|
3054
|
+
}, (0, react.createElement)("div", { className: "lks-acard-head" }, artifact.title), (0, react.createElement)("table", { className: "lks-acard-table" }, (0, react.createElement)("thead", null, (0, react.createElement)("tr", null, ...headers.map((h, i) => (0, react.createElement)("th", { key: i }, h)))), (0, react.createElement)("tbody", null, ...rows.map((row, r) => (0, react.createElement)("tr", { key: r }, ...row.map((c, i) => (0, react.createElement)("td", { key: i }, c)))))));
|
|
3055
|
+
}
|
|
3056
|
+
/** Code walkthrough card: line-numbered code + per-segment notes. */
|
|
3057
|
+
function CodeWalkthroughCard({ artifact }) {
|
|
3058
|
+
const language = typeof artifact.data.language === "string" ? artifact.data.language : "text";
|
|
3059
|
+
const code = typeof artifact.data.code === "string" ? artifact.data.code : "";
|
|
3060
|
+
const annotations = artifact.data.annotations ?? [];
|
|
3061
|
+
const lines = code.split("\n");
|
|
3062
|
+
return (0, react.createElement)("div", {
|
|
3063
|
+
className: "lks-acard",
|
|
3064
|
+
"data-lks-artifact": artifact.id
|
|
3065
|
+
}, (0, react.createElement)("div", { className: "lks-acard-head" }, `${artifact.title} · ${language}`), (0, react.createElement)("pre", { className: "lks-acard-code" }, ...lines.map((line, i) => (0, react.createElement)("span", {
|
|
3066
|
+
key: i,
|
|
3067
|
+
className: "lks-acard-line"
|
|
3068
|
+
}, (0, react.createElement)("i", null, String(i + 1)), line, "\n"))), (0, react.createElement)("div", { className: "lks-acard-notes" }, ...annotations.map((a, i) => (0, react.createElement)("div", {
|
|
3069
|
+
key: i,
|
|
3070
|
+
className: "lks-acard-note"
|
|
3071
|
+
}, (0, react.createElement)("b", null, `${tr("artifact.lines", {
|
|
3072
|
+
from: a.lineStart,
|
|
3073
|
+
to: a.lineEnd
|
|
3074
|
+
})}`), a.note))));
|
|
3075
|
+
}
|
|
3076
|
+
/** Mermaid diagram card with an expand modal (upstream DiagramViewerModal). */
|
|
3077
|
+
function DiagramCard({ artifact }) {
|
|
3078
|
+
const mermaid = typeof artifact.data.mermaid === "string" ? artifact.data.mermaid : "";
|
|
3079
|
+
const bodyRef = (0, react.useRef)(null);
|
|
3080
|
+
const [failed, setFailed] = (0, react.useState)(false);
|
|
3081
|
+
const [expanded, setExpanded] = (0, react.useState)(false);
|
|
3082
|
+
(0, react.useEffect)(() => {
|
|
3083
|
+
if (bodyRef.current === null) return;
|
|
3084
|
+
renderMermaidInto(bodyRef.current, mermaid, () => {
|
|
3085
|
+
setFailed(true);
|
|
3086
|
+
});
|
|
3087
|
+
}, [mermaid]);
|
|
3088
|
+
const body = failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", {
|
|
3089
|
+
className: "lks-acard-diagram",
|
|
3090
|
+
ref: bodyRef
|
|
3091
|
+
});
|
|
3092
|
+
return (0, react.createElement)("div", {
|
|
3093
|
+
className: "lks-acard",
|
|
3094
|
+
"data-lks-artifact": artifact.id
|
|
3095
|
+
}, (0, react.createElement)("div", { className: "lks-acard-head" }, artifact.title, (0, react.createElement)("button", {
|
|
3096
|
+
className: "lks-acard-expand",
|
|
3097
|
+
title: tr("artifact.expand"),
|
|
3098
|
+
onClick: () => {
|
|
3099
|
+
setExpanded(true);
|
|
3100
|
+
}
|
|
3101
|
+
}, "⤢")), body, expanded ? (0, react.createElement)("div", {
|
|
3102
|
+
className: "lks-acard-modal",
|
|
3103
|
+
onClick: () => {
|
|
3104
|
+
setExpanded(false);
|
|
3105
|
+
}
|
|
3106
|
+
}, (0, react.createElement)("div", {
|
|
3107
|
+
className: "lks-acard-modal-body",
|
|
3108
|
+
onClick: (e) => {
|
|
3109
|
+
e.stopPropagation();
|
|
3110
|
+
}
|
|
3111
|
+
}, (0, react.createElement)("div", { className: "lks-acard-modal-title" }, artifact.title, (0, react.createElement)("button", {
|
|
3112
|
+
className: "lks-acard-expand",
|
|
3113
|
+
onClick: () => {
|
|
3114
|
+
setExpanded(false);
|
|
3115
|
+
}
|
|
3116
|
+
}, "×")), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", { ref: (el) => {
|
|
3117
|
+
if (el !== null) renderMermaidInto(el, mermaid, () => {
|
|
3118
|
+
setFailed(true);
|
|
3119
|
+
});
|
|
3120
|
+
} }))) : null);
|
|
3121
|
+
}
|
|
3122
|
+
/** The opening guess: two big options, click = pick (sent to the tutor, who
|
|
3123
|
+
* reveals next turn); unscored — no right/wrong language anywhere. */
|
|
3124
|
+
function GuessCard({ artifact, send }) {
|
|
3125
|
+
const prompt = typeof artifact.data.prompt === "string" ? artifact.data.prompt : "";
|
|
3126
|
+
const options = artifact.data.options ?? [];
|
|
3127
|
+
const [picked, setPicked] = (0, react.useState)(null);
|
|
3128
|
+
(0, react.useEffect)(() => {
|
|
3129
|
+
setPicked(null);
|
|
3130
|
+
}, [artifact.id]);
|
|
3131
|
+
return (0, react.createElement)("div", {
|
|
3132
|
+
className: "lks-acard lks-acard-guess",
|
|
3133
|
+
"data-lks-artifact": artifact.id
|
|
3134
|
+
}, (0, react.createElement)("div", { className: "lks-acard-head" }, tr("artifact.guess.title")), (0, react.createElement)("div", { className: "lks-acard-guess-prompt" }, prompt), (0, react.createElement)("div", { className: "lks-acard-guess-opts" }, ...options.map((opt) => (0, react.createElement)("button", {
|
|
3135
|
+
key: opt.id,
|
|
3136
|
+
className: `lks-acard-guess-opt${picked === opt.id ? " picked" : ""}`,
|
|
3137
|
+
disabled: picked !== null,
|
|
3138
|
+
onClick: () => {
|
|
3139
|
+
setPicked(opt.id);
|
|
3140
|
+
send(tr("artifact.guess.pick", { label: opt.label }));
|
|
3141
|
+
}
|
|
3142
|
+
}, opt.label))), picked !== null ? (0, react.createElement)("div", { className: "lks-acard-guess-wait" }, tr("artifact.guess.wait")) : null);
|
|
3143
|
+
}
|
|
3144
|
+
/** Dispatch one artifact row to its card. */
|
|
3145
|
+
function ArtifactCard({ artifact, send }) {
|
|
3146
|
+
switch (artifact.artifactType) {
|
|
3147
|
+
case "compare_table": return (0, react.createElement)(CompareTableCard, { artifact });
|
|
3148
|
+
case "code_walkthrough": return (0, react.createElement)(CodeWalkthroughCard, { artifact });
|
|
3149
|
+
case "diagram": return (0, react.createElement)(DiagramCard, { artifact });
|
|
3150
|
+
case "guess": return (0, react.createElement)(GuessCard, {
|
|
3151
|
+
artifact,
|
|
3152
|
+
send
|
|
3153
|
+
});
|
|
3154
|
+
default: return null;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
//#endregion
|
|
3158
|
+
//#region src/client/companion.tsx
|
|
3159
|
+
/**
|
|
3160
|
+
* The companion creature — the upstream 伴学生物's minimal honest DOM port
|
|
3161
|
+
* (0.15.0 P7): a small being in the panel's bottom-right corner with five
|
|
3162
|
+
* forms, a pure mood machine (idle / talking / celebrating / encouraging),
|
|
3163
|
+
* and the a11y iron rule carried over — reduced-motion renders it static.
|
|
3164
|
+
* Upstream's Electron-native life (mic envelopes, typing squeeze, drag-throw)
|
|
3165
|
+
* has no web-panel equivalent and stays unported by design.
|
|
3166
|
+
* @module dsh-plugin-lookatstudy/client/companion
|
|
3167
|
+
*/
|
|
3168
|
+
const COMPANION_FORMS = [
|
|
3169
|
+
{
|
|
3170
|
+
id: "ember",
|
|
3171
|
+
labelKey: "companion.form.ember",
|
|
3172
|
+
body: "#f59e0b",
|
|
3173
|
+
accent: "#dc2626"
|
|
3174
|
+
},
|
|
3175
|
+
{
|
|
3176
|
+
id: "frost",
|
|
3177
|
+
labelKey: "companion.form.frost",
|
|
3178
|
+
body: "#7dd3fc",
|
|
3179
|
+
accent: "#0284c7"
|
|
3180
|
+
},
|
|
3181
|
+
{
|
|
3182
|
+
id: "moss",
|
|
3183
|
+
labelKey: "companion.form.moss",
|
|
3184
|
+
body: "#86efac",
|
|
3185
|
+
accent: "#16a34a"
|
|
3186
|
+
},
|
|
3187
|
+
{
|
|
3188
|
+
id: "star",
|
|
3189
|
+
labelKey: "companion.form.star",
|
|
3190
|
+
body: "#c4b5fd",
|
|
3191
|
+
accent: "#7c3aed"
|
|
3192
|
+
},
|
|
3193
|
+
{
|
|
3194
|
+
id: "ink",
|
|
3195
|
+
labelKey: "companion.form.ink",
|
|
3196
|
+
body: "#cbd5e1",
|
|
3197
|
+
accent: "#334155"
|
|
3198
|
+
}
|
|
3199
|
+
];
|
|
3200
|
+
/** Normalize a stored form id; junk falls back to 小焰 (upstream's default). */
|
|
3201
|
+
function normalizeCompanionForm(stored) {
|
|
3202
|
+
return COMPANION_FORMS.some((f) => f.id === stored) ? stored : "ember";
|
|
3203
|
+
}
|
|
3204
|
+
function storedCompanionForm() {
|
|
3205
|
+
try {
|
|
3206
|
+
return normalizeCompanionForm(localStorage.getItem("dsh-plugin-lookatstudy:companion-form"));
|
|
3207
|
+
} catch {
|
|
3208
|
+
return "ember";
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
function storeCompanionForm(id) {
|
|
3212
|
+
try {
|
|
3213
|
+
localStorage.setItem("dsh-plugin-lookatstudy:companion-form", id);
|
|
3214
|
+
} catch {}
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* The mood machine — pure. `celebrate` and `encourage` are momentary (the
|
|
3218
|
+
* view decays them back through `decay`); `talking` latches while read-aloud
|
|
3219
|
+
* runs; poke is a self-echoing celebration.
|
|
3220
|
+
*/
|
|
3221
|
+
function nextCompanionMood(current, event) {
|
|
3222
|
+
switch (event) {
|
|
3223
|
+
case "talk-start": return "talking";
|
|
3224
|
+
case "talk-end": return current === "talking" ? "idle" : current;
|
|
3225
|
+
case "celebrate":
|
|
3226
|
+
case "poke": return "celebrating";
|
|
3227
|
+
case "encourage": return "encouraging";
|
|
3228
|
+
case "decay": return current === "celebrating" || current === "encouraging" ? "idle" : current;
|
|
3229
|
+
default: return current;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
/** One creature face — the shared skin every form wears. */
|
|
3233
|
+
function CompanionFace({ form, mood }) {
|
|
3234
|
+
const skin = COMPANION_FORMS.find((f) => f.id === form) ?? COMPANION_FORMS[0];
|
|
3235
|
+
const eye = mood === "celebrating" ? "M -3.4 0 q 1.7 -2.6 3.4 0" : "M -3.4 -1.4 a 1.4 1.4 0 1 0 0.01 0";
|
|
3236
|
+
const mouth = mood === "celebrating" ? "M 20 21 q 4 4 8 0" : mood === "encouraging" ? "M 21 22 q 3 -2.5 6 0" : "M 21 21 q 3 2.4 6 0";
|
|
3237
|
+
return (0, react.createElement)("svg", {
|
|
3238
|
+
width: 64,
|
|
3239
|
+
height: 64,
|
|
3240
|
+
viewBox: "0 0 64 64",
|
|
3241
|
+
"aria-hidden": "true"
|
|
3242
|
+
}, (0, react.createElement)("g", null, (0, react.createElement)("circle", {
|
|
3243
|
+
cx: 32,
|
|
3244
|
+
cy: 34,
|
|
3245
|
+
r: 20,
|
|
3246
|
+
fill: skin.body,
|
|
3247
|
+
stroke: skin.accent,
|
|
3248
|
+
strokeWidth: 2
|
|
3249
|
+
}), form === "ember" ? (0, react.createElement)("path", {
|
|
3250
|
+
d: "M32 4 q6 8 3 12 q8 -2 9 6 q4 -1 4 4 l-2 4 H18 l-2 -4 q0 -5 4 -4 q1 -8 9 -6 q-3 -4 3 -12z",
|
|
3251
|
+
fill: skin.accent,
|
|
3252
|
+
opacity: .85
|
|
3253
|
+
}) : form === "star" ? (0, react.createElement)("path", {
|
|
3254
|
+
d: "M32 2 l4 8 9 1 -6.5 6 1.5 9 -8.5 -4.5 -8 4.5 1.5 -9 -6.5 -6 9 -1z",
|
|
3255
|
+
fill: skin.accent,
|
|
3256
|
+
opacity: .85
|
|
3257
|
+
}) : form === "moss" ? (0, react.createElement)("path", {
|
|
3258
|
+
d: "M32 2 q-2 6 2 10 q-8 0 -8 6 H40 q0 -6 -8 -6 q4 -4 0 -10z",
|
|
3259
|
+
fill: skin.accent,
|
|
3260
|
+
opacity: .85
|
|
3261
|
+
}) : form === "ink" ? (0, react.createElement)("path", {
|
|
3262
|
+
d: "M32 2 q8 12 8 18 a8 8 0 1 1 -16 0 q0 -6 8 -18z",
|
|
3263
|
+
fill: skin.accent,
|
|
3264
|
+
opacity: .55
|
|
3265
|
+
}) : (0, react.createElement)("path", {
|
|
3266
|
+
d: "M18 12 l3 5 5 -1 -1 5 5 3 -5 3 1 5 -5 -1 -3 5 -3 -5 -5 1 1 -5 -5 -3 5 -3 -1 -5 5 1z",
|
|
3267
|
+
fill: skin.accent,
|
|
3268
|
+
opacity: .7
|
|
3269
|
+
}), (0, react.createElement)("path", {
|
|
3270
|
+
d: eye,
|
|
3271
|
+
transform: "translate(25 30)",
|
|
3272
|
+
stroke: skin.accent,
|
|
3273
|
+
strokeWidth: 1.6,
|
|
3274
|
+
fill: mood === "celebrating" ? "none" : skin.accent,
|
|
3275
|
+
strokeLinecap: "round"
|
|
3276
|
+
}), (0, react.createElement)("path", {
|
|
3277
|
+
d: eye,
|
|
3278
|
+
transform: "translate(14 30)",
|
|
3279
|
+
stroke: skin.accent,
|
|
3280
|
+
strokeWidth: 1.6,
|
|
3281
|
+
fill: mood === "celebrating" ? "none" : skin.accent,
|
|
3282
|
+
strokeLinecap: "round"
|
|
3283
|
+
}), (0, react.createElement)("path", {
|
|
3284
|
+
d: mouth,
|
|
3285
|
+
stroke: skin.accent,
|
|
3286
|
+
strokeWidth: 1.8,
|
|
3287
|
+
fill: "none",
|
|
3288
|
+
strokeLinecap: "round"
|
|
3289
|
+
})));
|
|
3290
|
+
}
|
|
3291
|
+
/** The creature: fixed bottom-right inside the panel, mood-animated, pokeable. */
|
|
3292
|
+
function Companion({ mood, onPoke }) {
|
|
3293
|
+
const form = storedCompanionForm();
|
|
3294
|
+
return (0, react.createElement)("div", {
|
|
3295
|
+
className: `lks-companion mood-${mood}`,
|
|
3296
|
+
"data-lks-companion": form,
|
|
3297
|
+
title: tr("companion.poke"),
|
|
3298
|
+
role: "button",
|
|
3299
|
+
tabIndex: 0,
|
|
3300
|
+
"aria-label": tr("companion.poke"),
|
|
3301
|
+
onClick: () => {
|
|
3302
|
+
onPoke?.();
|
|
3303
|
+
},
|
|
3304
|
+
onKeyDown: (e) => {
|
|
3305
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
3306
|
+
e.preventDefault();
|
|
3307
|
+
onPoke?.();
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
}, (0, react.createElement)(CompanionFace, {
|
|
3311
|
+
form,
|
|
3312
|
+
mood
|
|
3313
|
+
}));
|
|
3314
|
+
}
|
|
3315
|
+
/** The hook driving one creature instance: event → mood, momentary decay. */
|
|
3316
|
+
function useCompanionMood() {
|
|
3317
|
+
const [mood, setMood] = (0, react.useState)("idle");
|
|
3318
|
+
const moodRef = (0, react.useRef)(mood);
|
|
3319
|
+
moodRef.current = mood;
|
|
3320
|
+
const emit = (event) => {
|
|
3321
|
+
const next = nextCompanionMood(moodRef.current, event);
|
|
3322
|
+
if (next !== moodRef.current) {
|
|
3323
|
+
moodRef.current = next;
|
|
3324
|
+
setMood(next);
|
|
3325
|
+
}
|
|
3326
|
+
};
|
|
3327
|
+
(0, react.useEffect)(() => {
|
|
3328
|
+
if (mood !== "celebrating" && mood !== "encouraging") return;
|
|
3329
|
+
const timer = setTimeout(() => {
|
|
3330
|
+
moodRef.current = nextCompanionMood(moodRef.current, "decay");
|
|
3331
|
+
setMood(moodRef.current);
|
|
3332
|
+
}, 2400);
|
|
3333
|
+
return () => {
|
|
3334
|
+
clearTimeout(timer);
|
|
3335
|
+
};
|
|
3336
|
+
}, [mood]);
|
|
3337
|
+
return {
|
|
3338
|
+
mood,
|
|
3339
|
+
emit
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
//#endregion
|
|
3343
|
+
//#region src/client/highlights.ts
|
|
3344
|
+
/**
|
|
3345
|
+
* Locate a selection inside the model text: exact `selected` text search,
|
|
3346
|
+
* disambiguated by a fuzzy `surrounding` fingerprint (the same sentence
|
|
3347
|
+
* appearing twice resolves to the occurrence whose context matches).
|
|
3348
|
+
* @returns the global [start, end) in model.text, or null when absent.
|
|
3349
|
+
*/
|
|
3350
|
+
function locateInModel(model, selected, surrounding) {
|
|
3351
|
+
const needle = selected.trim();
|
|
3352
|
+
if (needle.length < 2) return null;
|
|
3353
|
+
const hits = [];
|
|
3354
|
+
let from = 0;
|
|
3355
|
+
for (;;) {
|
|
3356
|
+
const hit = model.text.indexOf(needle, from);
|
|
3357
|
+
if (hit === -1) break;
|
|
3358
|
+
hits.push(hit);
|
|
3359
|
+
from = hit + 1;
|
|
3360
|
+
}
|
|
3361
|
+
if (hits.length === 0) return null;
|
|
3362
|
+
if (hits.length === 1 || surrounding === void 0 || surrounding.trim() === "") return {
|
|
3363
|
+
start: hits[0],
|
|
3364
|
+
end: hits[0] + needle.length
|
|
3365
|
+
};
|
|
3366
|
+
const finger = surrounding.replace(/\s+/g, "");
|
|
3367
|
+
let best = hits[0];
|
|
3368
|
+
let bestScore = -1;
|
|
3369
|
+
for (const hit of hits) {
|
|
3370
|
+
const context = model.text.slice(Math.max(0, hit - 30), hit + needle.length + 30).replace(/\s+/g, "");
|
|
3371
|
+
let score = 0;
|
|
3372
|
+
for (let i = 0; i < finger.length; i += 4) if (context.includes(finger.slice(i, i + 4))) score += 1;
|
|
3373
|
+
if (score > bestScore) {
|
|
3374
|
+
bestScore = score;
|
|
3375
|
+
best = hit;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
return {
|
|
3379
|
+
start: best,
|
|
3380
|
+
end: best + needle.length
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
/** Plan the per-node segments covering global [start, end) — pure. */
|
|
3384
|
+
function planSegments(nodes, start, end) {
|
|
3385
|
+
const segments = [];
|
|
3386
|
+
nodes.forEach((entry, index) => {
|
|
3387
|
+
if (entry.end <= start || entry.start >= end) return;
|
|
3388
|
+
segments.push({
|
|
3389
|
+
index,
|
|
3390
|
+
localStart: Math.max(0, start - entry.start),
|
|
3391
|
+
localEnd: Math.min(entry.end - entry.start, end - entry.start)
|
|
3392
|
+
});
|
|
3393
|
+
});
|
|
3394
|
+
return segments;
|
|
3395
|
+
}
|
|
3396
|
+
/** Build the DOM text model for the prose container (the walker rules mirror
|
|
3397
|
+
* upstream: skip script/style and KaTeX glyph layers; skip existing marks so
|
|
3398
|
+
* save-time and apply-time models stay symmetric). */
|
|
3399
|
+
function getTextModel(container, opts) {
|
|
3400
|
+
const nodes = [];
|
|
3401
|
+
let text = "";
|
|
3402
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode(node) {
|
|
3403
|
+
const parent = node.parentElement;
|
|
3404
|
+
if (parent === null) return NodeFilter.FILTER_REJECT;
|
|
3405
|
+
const tag = parent.tagName;
|
|
3406
|
+
if (tag === "SCRIPT" || tag === "STYLE") return NodeFilter.FILTER_REJECT;
|
|
3407
|
+
if (parent.closest(".katex-html")) return NodeFilter.FILTER_REJECT;
|
|
3408
|
+
if (parent.closest(".katex-mathml") !== null && parent.closest("annotation") === null) return NodeFilter.FILTER_REJECT;
|
|
3409
|
+
if (opts?.includeMarks !== true && parent.closest("mark.lks-hl") !== null) return NodeFilter.FILTER_REJECT;
|
|
3410
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
3411
|
+
} });
|
|
3412
|
+
let n;
|
|
3413
|
+
while ((n = walker.nextNode()) !== null) {
|
|
3414
|
+
const t = n;
|
|
3415
|
+
const content = t.textContent ?? "";
|
|
3416
|
+
nodes.push({
|
|
3417
|
+
node: t,
|
|
3418
|
+
start: text.length,
|
|
3419
|
+
end: text.length + content.length
|
|
3420
|
+
});
|
|
3421
|
+
text += content;
|
|
3422
|
+
}
|
|
3423
|
+
return {
|
|
3424
|
+
text,
|
|
3425
|
+
nodes
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
/** The mark class applied to persisted highlights. */
|
|
3429
|
+
const HIGHLIGHT_MARK = "lks-hl";
|
|
3430
|
+
/**
|
|
3431
|
+
* Apply one highlight range to the live DOM: split the boundary text nodes
|
|
3432
|
+
* and wrap the covering segments in marks. Returns the created marks.
|
|
3433
|
+
*/
|
|
3434
|
+
function wrapRange(model, start, end) {
|
|
3435
|
+
const marks = [];
|
|
3436
|
+
for (const seg of planSegments(model.nodes, start, end)) {
|
|
3437
|
+
const node = model.nodes[seg.index].node;
|
|
3438
|
+
if (node.parentElement === null) continue;
|
|
3439
|
+
if (node.parentElement.closest(`mark.lks-hl`) !== null) continue;
|
|
3440
|
+
const mid = seg.localStart > 0 ? node.splitText(seg.localStart) : node;
|
|
3441
|
+
const localEnd = seg.localEnd - seg.localStart;
|
|
3442
|
+
if (mid.textContent !== null && mid.textContent.length > localEnd) mid.splitText(localEnd);
|
|
3443
|
+
const mark = document.createElement("mark");
|
|
3444
|
+
mark.className = HIGHLIGHT_MARK;
|
|
3445
|
+
mid.parentElement.insertBefore(mark, mid);
|
|
3446
|
+
mark.appendChild(mid);
|
|
3447
|
+
marks.push(mark);
|
|
3448
|
+
}
|
|
3449
|
+
return marks;
|
|
3450
|
+
}
|
|
3451
|
+
/**
|
|
3452
|
+
* Render the persisted highlights for one lesson: each record-zone note with a
|
|
3453
|
+
* quote anchors a mark. Idempotent per call — call on a freshly rendered
|
|
3454
|
+
* (pre-mark) container; the model skips existing marks so re-entry is safe.
|
|
3455
|
+
* @returns how many notes got at least one mark.
|
|
3456
|
+
*/
|
|
3457
|
+
function applyHighlights(container, quotes) {
|
|
3458
|
+
let applied = 0;
|
|
3459
|
+
for (const quote of quotes) {
|
|
3460
|
+
const trimmed = quote.trim();
|
|
3461
|
+
if (trimmed.length < 2) continue;
|
|
3462
|
+
const model = getTextModel(container);
|
|
3463
|
+
const located = locateInModel(model, trimmed, void 0);
|
|
3464
|
+
if (located === null) continue;
|
|
3465
|
+
if (wrapRange(model, located.start, located.end).length > 0) applied += 1;
|
|
3466
|
+
}
|
|
3467
|
+
return applied;
|
|
3468
|
+
}
|
|
3469
|
+
//#endregion
|
|
2358
3470
|
//#region src/client/views.tsx
|
|
2359
3471
|
/**
|
|
2360
3472
|
* Shared pure helpers for the study panel's pieces: chat-row shapes, tree
|
|
@@ -2394,7 +3506,13 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2394
3506
|
run = [];
|
|
2395
3507
|
};
|
|
2396
3508
|
for (const line of text.split("\n")) {
|
|
2397
|
-
const
|
|
3509
|
+
const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
|
|
3510
|
+
const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
|
|
3511
|
+
const match = plain ?? (table !== null ? [
|
|
3512
|
+
table[0],
|
|
3513
|
+
table[1],
|
|
3514
|
+
table[2].replaceAll("**", "").replaceAll("`", "")
|
|
3515
|
+
] : null);
|
|
2398
3516
|
if (match === null) {
|
|
2399
3517
|
flush();
|
|
2400
3518
|
continue;
|
|
@@ -2416,6 +3534,56 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2416
3534
|
return runs.at(-1) ?? [];
|
|
2417
3535
|
}
|
|
2418
3536
|
/**
|
|
3537
|
+
* Merge the rail's two search tracks into the results panel: title hits
|
|
3538
|
+
* (lessons whose name matches — they are also filtered into the tree) first,
|
|
3539
|
+
* then full-text hits not already covered, snippets carried for the body
|
|
3540
|
+
* matches. Pure; empty queries yield no panel.
|
|
3541
|
+
*/
|
|
3542
|
+
function mergeRailSearch(query, lessons, textHits) {
|
|
3543
|
+
const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
3544
|
+
if (keys.length === 0) return [];
|
|
3545
|
+
const rows = [];
|
|
3546
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3547
|
+
const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
|
|
3548
|
+
for (const lesson of lessons) {
|
|
3549
|
+
if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
|
|
3550
|
+
rows.push({
|
|
3551
|
+
lessonId: lesson.id,
|
|
3552
|
+
title: lesson.title,
|
|
3553
|
+
snippet: "",
|
|
3554
|
+
courseTitle: "",
|
|
3555
|
+
fromTitle: true
|
|
3556
|
+
});
|
|
3557
|
+
seen.add(lesson.id);
|
|
3558
|
+
}
|
|
3559
|
+
for (const hit of textHits) {
|
|
3560
|
+
if (seen.has(hit.lessonId)) continue;
|
|
3561
|
+
rows.push({
|
|
3562
|
+
lessonId: hit.lessonId,
|
|
3563
|
+
title: hit.lessonTitle,
|
|
3564
|
+
snippet: hit.snippet,
|
|
3565
|
+
courseTitle: hit.courseTitle,
|
|
3566
|
+
fromTitle: false
|
|
3567
|
+
});
|
|
3568
|
+
seen.add(hit.lessonId);
|
|
3569
|
+
}
|
|
3570
|
+
return rows.slice(0, 12);
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* A section's effective openness: the user's explicit toggle (persisted per
|
|
3574
|
+
* course) overrides the frontier-based default. Pure.
|
|
3575
|
+
*/
|
|
3576
|
+
function effectiveOpen(sectionTitle, defaultOpen, overrides) {
|
|
3577
|
+
return overrides[sectionTitle] ?? defaultOpen;
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* The narrow-mode pane picker: only valid ids, remembering the last choice
|
|
3581
|
+
* (upstream's narrow switcher semantics). Pure.
|
|
3582
|
+
*/
|
|
3583
|
+
function pickNarrowPane(stored) {
|
|
3584
|
+
return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
|
|
3585
|
+
}
|
|
3586
|
+
/**
|
|
2419
3587
|
* Default expansion for one rail section: collapsed when every study lesson is
|
|
2420
3588
|
* done (mastered) or not yet reachable (locked). The focus lesson's section
|
|
2421
3589
|
* stays open; exam nodes never force a section open. Pure.
|
|
@@ -2469,12 +3637,43 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2469
3637
|
if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
|
|
2470
3638
|
return (0, react.createElement)(IconLockFill16, { size: 14 });
|
|
2471
3639
|
}
|
|
3640
|
+
/** Toast severity glyph (P6): the state color rides the icon, text stays ink. */
|
|
3641
|
+
function toastIcon(severity) {
|
|
3642
|
+
if (severity === "success") return (0, react.createElement)(IconStarFill16, {
|
|
3643
|
+
size: 14,
|
|
3644
|
+
className: "lks-toast-glyph ok"
|
|
3645
|
+
});
|
|
3646
|
+
if (severity === "warning") return (0, react.createElement)(IconWarningOutline16, {
|
|
3647
|
+
size: 14,
|
|
3648
|
+
className: "lks-toast-glyph warn"
|
|
3649
|
+
});
|
|
3650
|
+
if (severity === "error") return (0, react.createElement)(IconWarningOutline16, {
|
|
3651
|
+
size: 14,
|
|
3652
|
+
className: "lks-toast-glyph err"
|
|
3653
|
+
});
|
|
3654
|
+
if (severity === "info") return (0, react.createElement)(IconGlobeOutline14, {
|
|
3655
|
+
size: 13,
|
|
3656
|
+
className: "lks-toast-glyph info"
|
|
3657
|
+
});
|
|
3658
|
+
return null;
|
|
3659
|
+
}
|
|
2472
3660
|
function examOpen(lessons) {
|
|
2473
3661
|
return lessons.every((l) => l.kind !== "study" || (l.masteryPct ?? 0) >= 50);
|
|
2474
3662
|
}
|
|
2475
3663
|
function titleMatches(title, query) {
|
|
2476
3664
|
return query.trim().toLowerCase().split(/\s+/).filter(Boolean).every((k) => title.toLowerCase().includes(k));
|
|
2477
3665
|
}
|
|
3666
|
+
/**
|
|
3667
|
+
* Whether the host session list actually knows a bound thread id. A restart
|
|
3668
|
+
* can drop young sessions from persistence; an empty byId means the list has
|
|
3669
|
+
* not hydrated yet (assume known — the send's own open surfaces a real error).
|
|
3670
|
+
* Pure over the injected list snapshot.
|
|
3671
|
+
*/
|
|
3672
|
+
function sessionKnown(ctx, id) {
|
|
3673
|
+
const byId = (ctx.sessions.list?.getSnapshot())?.byId;
|
|
3674
|
+
if (byId === void 0) return true;
|
|
3675
|
+
return Object.keys(byId).length === 0 ? true : id in byId;
|
|
3676
|
+
}
|
|
2478
3677
|
/** One rendered chat row (shared shape with the read-aloud era's renderer). */
|
|
2479
3678
|
function chatRow(row, interactive) {
|
|
2480
3679
|
if (row.role === "user") return (0, react.createElement)("div", {
|
|
@@ -2520,26 +3719,95 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2520
3719
|
const [sendError, setSendError] = (0, react.useState)(null);
|
|
2521
3720
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
2522
3721
|
const [rows, setRows] = (0, react.useState)([]);
|
|
3722
|
+
const [feedAttached, setFeedAttached] = (0, react.useState)(false);
|
|
2523
3723
|
const [draft, setDraft] = (0, react.useState)("");
|
|
3724
|
+
const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
|
|
2524
3725
|
const lesson = data?.lesson ?? null;
|
|
2525
3726
|
const localBound = (0, react.useRef)(null);
|
|
2526
3727
|
const streamEl = (0, react.useRef)(null);
|
|
2527
3728
|
const boundId = localBound.current ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
|
|
3729
|
+
const nudged = (0, react.useRef)(false);
|
|
3730
|
+
(0, react.useEffect)(() => {
|
|
3731
|
+
if (nudged.current || (data?.dueCount ?? 0) === 0) return;
|
|
3732
|
+
nudged.current = true;
|
|
3733
|
+
showStudyToast(tr("review.nudge", { n: data.dueCount }), {
|
|
3734
|
+
severity: "warning",
|
|
3735
|
+
action: {
|
|
3736
|
+
label: tr("review.nudge.go"),
|
|
3737
|
+
onClick: () => {
|
|
3738
|
+
const first = data?.due[0];
|
|
3739
|
+
if (first !== void 0) setFocus(first.lessonId).catch(() => {});
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
});
|
|
3743
|
+
}, [
|
|
3744
|
+
data?.dueCount,
|
|
3745
|
+
data?.due,
|
|
3746
|
+
setFocus
|
|
3747
|
+
]);
|
|
2528
3748
|
(0, react.useEffect)(() => {
|
|
2529
3749
|
if (boundId === null) {
|
|
2530
3750
|
setRows([]);
|
|
3751
|
+
setFeedAttached(false);
|
|
2531
3752
|
return;
|
|
2532
3753
|
}
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
3754
|
+
setFeedAttached(false);
|
|
3755
|
+
let disposed = false;
|
|
3756
|
+
let offSource;
|
|
3757
|
+
let offList;
|
|
3758
|
+
const attach = () => {
|
|
3759
|
+
const source = ctx.sessions.binding(boundId)?.eventSource;
|
|
3760
|
+
if (source === void 0) return false;
|
|
3761
|
+
const update = () => {
|
|
3762
|
+
setRows(feedRows(source.getSnapshot()));
|
|
3763
|
+
};
|
|
3764
|
+
update();
|
|
3765
|
+
setFeedAttached(true);
|
|
3766
|
+
offSource = source.subscribe(update);
|
|
3767
|
+
return true;
|
|
3768
|
+
};
|
|
3769
|
+
const stage = () => {
|
|
3770
|
+
if (!sessionKnown(ctx, boundId)) return;
|
|
3771
|
+
try {
|
|
3772
|
+
const shell = PANEL_SHELL.current;
|
|
3773
|
+
if (shell !== null) shell.suppressHandBack(() => {
|
|
3774
|
+
ctx.sessions.open(boundId);
|
|
3775
|
+
});
|
|
3776
|
+
else ctx.sessions.open(boundId);
|
|
3777
|
+
} catch {}
|
|
3778
|
+
};
|
|
3779
|
+
const reconcile = () => {
|
|
3780
|
+
if (disposed || offSource !== void 0) return true;
|
|
3781
|
+
if (ctx.sessions.list?.getSnapshot().current !== boundId) {
|
|
3782
|
+
stage();
|
|
3783
|
+
return false;
|
|
3784
|
+
}
|
|
3785
|
+
return attach();
|
|
3786
|
+
};
|
|
3787
|
+
if (!reconcile()) {
|
|
3788
|
+
let tries = 0;
|
|
3789
|
+
const timer = setInterval(() => {
|
|
3790
|
+
if (disposed || offSource !== void 0 || reconcile() || ++tries >= 40) clearInterval(timer);
|
|
3791
|
+
}, 150);
|
|
3792
|
+
const list = ctx.sessions.list;
|
|
3793
|
+
if (list !== void 0) offList = list.subscribe(() => {
|
|
3794
|
+
if (disposed || reconcile()) {
|
|
3795
|
+
offList?.();
|
|
3796
|
+
offList = void 0;
|
|
3797
|
+
}
|
|
3798
|
+
});
|
|
3799
|
+
return () => {
|
|
3800
|
+
disposed = true;
|
|
3801
|
+
clearInterval(timer);
|
|
3802
|
+
offSource?.();
|
|
3803
|
+
offList?.();
|
|
3804
|
+
};
|
|
2537
3805
|
}
|
|
2538
|
-
|
|
2539
|
-
|
|
3806
|
+
return () => {
|
|
3807
|
+
disposed = true;
|
|
3808
|
+
offSource?.();
|
|
3809
|
+
offList?.();
|
|
2540
3810
|
};
|
|
2541
|
-
update();
|
|
2542
|
-
return source.subscribe(update);
|
|
2543
3811
|
}, [boundId, ctx]);
|
|
2544
3812
|
(0, react.useEffect)(() => {
|
|
2545
3813
|
const el = streamEl.current;
|
|
@@ -2553,8 +3821,9 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2553
3821
|
setSendError(null);
|
|
2554
3822
|
try {
|
|
2555
3823
|
if (data?.active !== true) await activate(true);
|
|
2556
|
-
let sessionId = localBound.current ?? data?.lessonSessions[lesson.lessonId];
|
|
2557
|
-
if (sessionId
|
|
3824
|
+
let sessionId = localBound.current ?? data?.lessonSessions[lesson.lessonId] ?? null;
|
|
3825
|
+
if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
|
|
3826
|
+
if (sessionId === null) {
|
|
2558
3827
|
const area = await fetch("/lookatstudy/api/study-workspace");
|
|
2559
3828
|
if (!area.ok) throw new Error(`study area unavailable (HTTP ${String(area.status)})`);
|
|
2560
3829
|
const { path } = await area.json();
|
|
@@ -2584,7 +3853,12 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2584
3853
|
}
|
|
2585
3854
|
})();
|
|
2586
3855
|
};
|
|
2587
|
-
const
|
|
3856
|
+
const companion = useCompanionMood();
|
|
3857
|
+
const setCompanionEvent = companion.emit;
|
|
3858
|
+
const body = (0, react.createElement)("div", {
|
|
3859
|
+
className: "lks14-body",
|
|
3860
|
+
"data-pane": narrowPane
|
|
3861
|
+
}, (0, react.createElement)(CourseRail, {
|
|
2588
3862
|
data,
|
|
2589
3863
|
activate,
|
|
2590
3864
|
setFocus,
|
|
@@ -2595,20 +3869,85 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2595
3869
|
data,
|
|
2596
3870
|
lesson,
|
|
2597
3871
|
rows,
|
|
3872
|
+
feedAttached,
|
|
3873
|
+
bound: boundId !== null,
|
|
2598
3874
|
busy,
|
|
2599
3875
|
sendError,
|
|
2600
3876
|
draft,
|
|
2601
3877
|
setDraft,
|
|
2602
3878
|
send,
|
|
2603
|
-
setMode
|
|
3879
|
+
setMode,
|
|
3880
|
+
narrowPane,
|
|
3881
|
+
companionEvent: setCompanionEvent
|
|
2604
3882
|
}), (0, react.createElement)(NotebookPane, {
|
|
2605
3883
|
data,
|
|
2606
|
-
deleteNote
|
|
3884
|
+
deleteNote,
|
|
3885
|
+
send,
|
|
3886
|
+
companionEvent: setCompanionEvent
|
|
2607
3887
|
}));
|
|
2608
3888
|
return (0, react.createElement)("div", {
|
|
2609
3889
|
className: "lks14",
|
|
2610
3890
|
"data-lks-panel": ""
|
|
2611
|
-
},
|
|
3891
|
+
}, (0, react.createElement)(Companion, {
|
|
3892
|
+
mood: companion.mood,
|
|
3893
|
+
onPoke: () => {
|
|
3894
|
+
setCompanionEvent("poke");
|
|
3895
|
+
showStudyToast(tr("companion.poked"), { severity: "success" });
|
|
3896
|
+
}
|
|
3897
|
+
}), (0, react.createElement)("div", { className: "lks14-switch" }, ...[
|
|
3898
|
+
"rail",
|
|
3899
|
+
"chat",
|
|
3900
|
+
"note"
|
|
3901
|
+
].map((pane) => (0, react.createElement)("button", {
|
|
3902
|
+
key: pane,
|
|
3903
|
+
className: `lks14-switch-btn${narrowPane === pane ? " on" : ""}`,
|
|
3904
|
+
"aria-pressed": String(narrowPane === pane),
|
|
3905
|
+
onClick: () => {
|
|
3906
|
+
setNarrowPane(pane);
|
|
3907
|
+
}
|
|
3908
|
+
}, tr(`pane.${pane}`)))), body, (0, react.createElement)(StudyToastStack));
|
|
3909
|
+
}
|
|
3910
|
+
/** P6: the panel's toast stack (upstream Toast port) — severity capsules,
|
|
3911
|
+
* top-center, auto-dismiss with an exit-animation handshake; the store clears
|
|
3912
|
+
* on unmount so no toast outlives the panel. */
|
|
3913
|
+
function StudyToastStack() {
|
|
3914
|
+
const [items, setItems] = (0, react.useState)([]);
|
|
3915
|
+
(0, react.useEffect)(() => {
|
|
3916
|
+
const update = () => {
|
|
3917
|
+
setItems([...toastStore.getSnapshot()]);
|
|
3918
|
+
};
|
|
3919
|
+
update();
|
|
3920
|
+
const unsubscribe = toastStore.subscribe(update);
|
|
3921
|
+
return () => {
|
|
3922
|
+
unsubscribe();
|
|
3923
|
+
toastStore.clear();
|
|
3924
|
+
};
|
|
3925
|
+
}, []);
|
|
3926
|
+
return (0, react.createElement)("div", {
|
|
3927
|
+
className: "lks-toasts",
|
|
3928
|
+
role: "region",
|
|
3929
|
+
"aria-live": "polite",
|
|
3930
|
+
"aria-label": tr("toast.region")
|
|
3931
|
+
}, ...items.map((t) => (0, react.createElement)("div", {
|
|
3932
|
+
key: t.id,
|
|
3933
|
+
className: `lks-toast${t.exiting ? " exiting" : ""}`,
|
|
3934
|
+
"data-severity": t.severity,
|
|
3935
|
+
onAnimationEnd: t.exiting ? () => {
|
|
3936
|
+
toastStore.finish(t.id);
|
|
3937
|
+
} : void 0
|
|
3938
|
+
}, toastIcon(t.severity), (0, react.createElement)("span", { className: "lks-toast-text" }, t.message), t.action === void 0 ? null : (0, react.createElement)("button", {
|
|
3939
|
+
className: "lks-toast-action",
|
|
3940
|
+
onClick: () => {
|
|
3941
|
+
t.action?.onClick?.();
|
|
3942
|
+
toastStore.startExit(t.id);
|
|
3943
|
+
}
|
|
3944
|
+
}, t.action.label), (0, react.createElement)("button", {
|
|
3945
|
+
className: "lks-toast-close",
|
|
3946
|
+
"aria-label": tr("toast.close"),
|
|
3947
|
+
onClick: () => {
|
|
3948
|
+
toastStore.startExit(t.id);
|
|
3949
|
+
}
|
|
3950
|
+
}, "×"))));
|
|
2612
3951
|
}
|
|
2613
3952
|
/** Module-level shell handle so the panel can suppress hand-back on internal opens. */
|
|
2614
3953
|
const PANEL_SHELL = { current: null };
|
|
@@ -2619,6 +3958,14 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2619
3958
|
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send }) {
|
|
2620
3959
|
const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
|
|
2621
3960
|
const [query, setQuery] = (0, react.useState)("");
|
|
3961
|
+
const [searchRows, setSearchRows] = (0, react.useState)([]);
|
|
3962
|
+
const [sectionOverrides, setSectionOverrides] = (0, react.useState)({});
|
|
3963
|
+
const toggleSection = (title, next) => {
|
|
3964
|
+
setSectionOverrides((cur) => ({
|
|
3965
|
+
...cur,
|
|
3966
|
+
[title]: next
|
|
3967
|
+
}));
|
|
3968
|
+
};
|
|
2622
3969
|
const [confirmDelete, setConfirmDelete] = (0, react.useState)(false);
|
|
2623
3970
|
const [showImport, setShowImport] = (0, react.useState)(false);
|
|
2624
3971
|
const [error, setError] = (0, react.useState)(null);
|
|
@@ -2640,6 +3987,36 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2640
3987
|
window.removeEventListener("keydown", onKey);
|
|
2641
3988
|
};
|
|
2642
3989
|
}, [confirmDelete]);
|
|
3990
|
+
(0, react.useEffect)(() => {
|
|
3991
|
+
if (query.trim().length < 2 || data === null) {
|
|
3992
|
+
setSearchRows([]);
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
let cancelled = false;
|
|
3996
|
+
const timer = setTimeout(() => {
|
|
3997
|
+
searchLessons(query).then((textHits) => {
|
|
3998
|
+
if (cancelled) return;
|
|
3999
|
+
const lessons = (data.courses.find((c) => c.courseId === (data.courses.some((x) => x.courseId === selectedCourse) ? selectedCourse : data.courses[0]?.courseId))?.sections ?? []).flatMap((s) => s.lessons);
|
|
4000
|
+
setSearchRows(mergeRailSearch(query, lessons.map((l) => ({
|
|
4001
|
+
id: l.id,
|
|
4002
|
+
title: l.title,
|
|
4003
|
+
status: l.status,
|
|
4004
|
+
kind: l.kind
|
|
4005
|
+
})), textHits));
|
|
4006
|
+
}).catch(() => {
|
|
4007
|
+
if (!cancelled) setSearchRows([]);
|
|
4008
|
+
});
|
|
4009
|
+
}, 250);
|
|
4010
|
+
return () => {
|
|
4011
|
+
cancelled = true;
|
|
4012
|
+
clearTimeout(timer);
|
|
4013
|
+
};
|
|
4014
|
+
}, [
|
|
4015
|
+
query,
|
|
4016
|
+
selectedCourse,
|
|
4017
|
+
data,
|
|
4018
|
+
searchLessons
|
|
4019
|
+
]);
|
|
2643
4020
|
const body = data === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("loading")) : data.courses.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("rail.empty.title"), (0, react.createElement)("br"), (0, react.createElement)("button", {
|
|
2644
4021
|
className: "lks-btn primary",
|
|
2645
4022
|
style: { margin: "10px 0" },
|
|
@@ -2691,25 +4068,34 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2691
4068
|
setQuery(e.target.value);
|
|
2692
4069
|
},
|
|
2693
4070
|
onKeyDown: (e) => {
|
|
4071
|
+
if (e.key === "Escape") {
|
|
4072
|
+
setQuery("");
|
|
4073
|
+
setSearchRows([]);
|
|
4074
|
+
}
|
|
2694
4075
|
if (e.key !== "Enter" || query.trim() === "") return;
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
}
|
|
4076
|
+
const first = searchRows[0];
|
|
4077
|
+
if (first !== void 0) {
|
|
4078
|
+
setQuery("");
|
|
4079
|
+
setSearchRows([]);
|
|
4080
|
+
setFocus(first.lessonId).catch(reportError);
|
|
2701
4081
|
}
|
|
2702
|
-
searchLessons(query).then((hits) => {
|
|
2703
|
-
const hit = hits.find((h) => h.lessonId.startsWith(`${courseId}:`)) ?? hits[0];
|
|
2704
|
-
if (hit !== void 0) {
|
|
2705
|
-
setQuery("");
|
|
2706
|
-
setFocus(hit.lessonId).catch(reportError);
|
|
2707
|
-
}
|
|
2708
|
-
}).catch(reportError);
|
|
2709
4082
|
}
|
|
2710
|
-
}),
|
|
4083
|
+
}), searchRows.length > 0 ? (0, react.createElement)("div", { className: "lks14-searchpanel" }, ...searchRows.map((row) => (0, react.createElement)("button", {
|
|
4084
|
+
key: row.lessonId,
|
|
4085
|
+
className: "lks14-searchrow",
|
|
4086
|
+
title: tr("rail.search.jump"),
|
|
4087
|
+
onClick: () => {
|
|
4088
|
+
setQuery("");
|
|
4089
|
+
setSearchRows([]);
|
|
4090
|
+
setFocus(row.lessonId).catch(reportError);
|
|
4091
|
+
}
|
|
4092
|
+
}, (0, react.createElement)("span", { className: "lks14-searchrow-title" }, row.title), row.courseTitle !== "" ? (0, react.createElement)("span", { className: "lks14-searchrow-course" }, row.courseTitle) : null, row.snippet !== "" ? (0, react.createElement)("span", { className: "lks14-searchrow-snip" }, row.snippet) : null))) : null, data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks14-duebox" }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("rail.due", { count: data.dueCount }), ...data.due.map((d) => (0, react.createElement)("button", {
|
|
2711
4093
|
key: d.lessonId,
|
|
2712
|
-
className: "lks14-dueitem"
|
|
4094
|
+
className: "lks14-dueitem",
|
|
4095
|
+
title: tr("review.jump"),
|
|
4096
|
+
onClick: () => {
|
|
4097
|
+
setFocus(d.lessonId).catch(reportError);
|
|
4098
|
+
}
|
|
2713
4099
|
}, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks14-over" }, tr("rail.due.over", { days: d.overdueDays })) : null)), (0, react.createElement)("button", {
|
|
2714
4100
|
className: "lks-btn ghost",
|
|
2715
4101
|
style: { marginTop: "6px" },
|
|
@@ -2720,14 +4106,16 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2720
4106
|
const examAllowed = examOpen(section.lessons);
|
|
2721
4107
|
const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
|
|
2722
4108
|
if (lessons.length === 0) return [];
|
|
2723
|
-
const open = query.trim() !== "" || sectionDefaultOpen(section);
|
|
4109
|
+
const open = query.trim() !== "" || effectiveOpen(section.title, sectionDefaultOpen(section), sectionOverrides);
|
|
2724
4110
|
return [(0, react.createElement)("button", {
|
|
2725
4111
|
key: section.title,
|
|
2726
4112
|
type: "button",
|
|
2727
4113
|
className: "lks14-sechead",
|
|
2728
4114
|
"aria-expanded": String(open),
|
|
2729
4115
|
title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
|
|
2730
|
-
onClick: () => {
|
|
4116
|
+
onClick: () => {
|
|
4117
|
+
toggleSection(section.title, !open);
|
|
4118
|
+
}
|
|
2731
4119
|
}, (0, react.createElement)("span", { className: "lks14-secnum" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks14-secheadt" }, section.title), (0, react.createElement)("span", { className: "lks14-secheadc" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
|
|
2732
4120
|
const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
|
|
2733
4121
|
return (0, react.createElement)("button", {
|
|
@@ -2780,7 +4168,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2780
4168
|
}, tr("rail.empty.button"))), (0, react.createElement)("div", { className: "lks14-hint" }, tr("rail.empty.hint"), (0, react.createElement)("br"), tr("rail.empty.hint2")));
|
|
2781
4169
|
}
|
|
2782
4170
|
/** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
|
|
2783
|
-
function ChatPane({ data, lesson, rows, busy, sendError, draft, setDraft, send, setMode }) {
|
|
4171
|
+
function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, setMode, narrowPane, companionEvent }) {
|
|
2784
4172
|
const streamEl = (0, react.useRef)(null);
|
|
2785
4173
|
(0, react.useEffect)(() => {
|
|
2786
4174
|
const el = streamEl.current;
|
|
@@ -2803,7 +4191,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2803
4191
|
if (draft.trim() !== "" && !busy) send(draft);
|
|
2804
4192
|
}
|
|
2805
4193
|
};
|
|
2806
|
-
|
|
4194
|
+
const proposal = data?.pendingProposals[0] ?? null;
|
|
4195
|
+
return (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, proposal !== null ? (0, react.createElement)("div", { className: "lks-propbanner" }, (0, react.createElement)(IconCrownFill16, { size: 14 }), (0, react.createElement)("span", { className: "lks-propbanner-why" }, tr("proposal.banner", { lesson: proposal.lessonTitle }), " — ", proposal.rationale), (0, react.createElement)("button", {
|
|
4196
|
+
className: "lks-btn primary",
|
|
4197
|
+
style: {
|
|
4198
|
+
padding: "4px 12px",
|
|
4199
|
+
fontSize: "12.5px"
|
|
4200
|
+
},
|
|
4201
|
+
onClick: () => {
|
|
4202
|
+
send(tr("proposal.accept.msg", { lesson: proposal.lessonTitle }));
|
|
4203
|
+
}
|
|
4204
|
+
}, tr("proposal.accept")), (0, react.createElement)("button", {
|
|
4205
|
+
className: "lks-btn ghost",
|
|
4206
|
+
style: {
|
|
4207
|
+
padding: "4px 12px",
|
|
4208
|
+
fontSize: "12.5px"
|
|
4209
|
+
},
|
|
4210
|
+
onClick: () => {
|
|
4211
|
+
send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
|
|
4212
|
+
}
|
|
4213
|
+
}, tr("proposal.decline"))) : null, (0, react.createElement)("div", { className: "lks14-colhead" }, tr("col.tutor"), lesson !== null ? (0, react.createElement)("span", { className: "lks14-chatlesson" }, lesson.title) : null, (0, react.createElement)("span", { className: "lks14-pills" }, ...MODES$1.map((mode) => (0, react.createElement)("button", {
|
|
2807
4214
|
key: mode.id,
|
|
2808
4215
|
className: `lks14-pill${data?.mode === mode.id ? " on" : ""}`,
|
|
2809
4216
|
"aria-pressed": String(data?.mode === mode.id),
|
|
@@ -2814,8 +4221,26 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2814
4221
|
}
|
|
2815
4222
|
}, tr(mode.labelKey))))), (0, react.createElement)("div", {
|
|
2816
4223
|
className: "lks14-stream",
|
|
2817
|
-
ref: streamEl
|
|
2818
|
-
|
|
4224
|
+
ref: streamEl,
|
|
4225
|
+
"data-lks-feed": rows.length > 0 ? `rows:${String(rows.length)}` : bound ? feedAttached ? "attached-empty" : "waiting" : "no-thread"
|
|
4226
|
+
}, rows.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, dormant ? tr("tutor.dormant") : tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRow(row, !dormant && i === lastAssistant ? { send } : void 0)), rows.length > 0 && rows[rows.length - 1].role === "user" ? (0, react.createElement)("div", { className: "lks14-thinking" }, (0, react.createElement)(IconLoadingOutline16, {
|
|
4227
|
+
size: 13,
|
|
4228
|
+
className: "lks-spin"
|
|
4229
|
+
}), tr("tutor.thinking")) : null), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType === "quiz").map((a) => (0, react.createElement)(QuizCard, {
|
|
4230
|
+
key: a.id,
|
|
4231
|
+
lessonId: lesson.lessonId,
|
|
4232
|
+
artifactId: a.id,
|
|
4233
|
+
data: a.data,
|
|
4234
|
+
masteryPct: lesson.masteryPct,
|
|
4235
|
+
send,
|
|
4236
|
+
onFinished: (allCorrect) => {
|
|
4237
|
+
companionEvent(allCorrect ? "celebrate" : "encourage");
|
|
4238
|
+
}
|
|
4239
|
+
})), ...(lesson?.artifacts ?? []).filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(ArtifactCard, {
|
|
4240
|
+
key: a.id,
|
|
4241
|
+
artifact: a,
|
|
4242
|
+
send
|
|
4243
|
+
})), (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
|
|
2819
4244
|
key: s.label,
|
|
2820
4245
|
className: "lks14-starter",
|
|
2821
4246
|
"aria-disabled": dormant || void 0,
|
|
@@ -2845,7 +4270,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2845
4270
|
}) : null, tr("composer.send"))), sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null);
|
|
2846
4271
|
}
|
|
2847
4272
|
/** 右栏:the notebook — 讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
|
|
2848
|
-
function NotebookPane({ data, deleteNote }) {
|
|
4273
|
+
function NotebookPane({ data, deleteNote, send, companionEvent }) {
|
|
2849
4274
|
const lesson = data?.lesson ?? null;
|
|
2850
4275
|
const [tab, setTab] = (0, react.useState)("teach");
|
|
2851
4276
|
const [error, setError] = (0, react.useState)(null);
|
|
@@ -2853,7 +4278,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2853
4278
|
const [read, setRead] = (0, react.useState)(null);
|
|
2854
4279
|
const readCtl = (0, react.useRef)(null);
|
|
2855
4280
|
const [readError, setReadError] = (0, react.useState)(null);
|
|
2856
|
-
const { tts } = useStudy();
|
|
4281
|
+
const { tts, addUserNote, recordReview } = useStudy();
|
|
2857
4282
|
const proseRef = (0, react.useRef)(null);
|
|
2858
4283
|
const diagRef = (0, react.useRef)(null);
|
|
2859
4284
|
(0, react.useEffect)(() => {
|
|
@@ -2878,8 +4303,94 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2878
4303
|
setError(err instanceof Error ? err.message : String(err));
|
|
2879
4304
|
});
|
|
2880
4305
|
};
|
|
4306
|
+
const artifacts = lesson?.artifacts ?? [];
|
|
4307
|
+
const [, setUnseenTick] = (0, react.useState)(0);
|
|
4308
|
+
const unseen = unseenArtifacts(lesson?.lessonId ?? "", artifacts);
|
|
4309
|
+
const lastAnnounced = (0, react.useRef)(/* @__PURE__ */ new Set());
|
|
4310
|
+
(0, react.useEffect)(() => {
|
|
4311
|
+
const fresh = unseen.filter((id) => !lastAnnounced.current.has(id));
|
|
4312
|
+
if (fresh.length === 0) return;
|
|
4313
|
+
lastAnnounced.current = /* @__PURE__ */ new Set([...lastAnnounced.current, ...fresh]);
|
|
4314
|
+
showStudyToast(tr("artifact.sedimented"), { severity: "info" });
|
|
4315
|
+
setUnseenTick(Date.now());
|
|
4316
|
+
}, [unseen.join(","), lesson?.lessonId]);
|
|
4317
|
+
const heavy = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
|
|
4318
|
+
const latestHeavy = heavy.length > 0 ? heavy[heavy.length - 1] : null;
|
|
4319
|
+
const [quoteBtn, setQuoteBtn] = (0, react.useState)(null);
|
|
4320
|
+
const recordQuotes = (lesson?.notes ?? []).filter((n) => n.zone === "record" && n.quote !== null && n.quote.trim().length >= 2).map((n) => n.quote);
|
|
4321
|
+
const evaluateSelection = () => {
|
|
4322
|
+
const prose = proseRef.current;
|
|
4323
|
+
const selection = window.getSelection();
|
|
4324
|
+
if (prose === null || selection === null || selection.rangeCount === 0) return;
|
|
4325
|
+
const text = selection.toString().trim();
|
|
4326
|
+
if (text.length < 2 || text.length > 600) return;
|
|
4327
|
+
const range = selection.getRangeAt(0);
|
|
4328
|
+
if (prose.contains(range.commonAncestorContainer) !== true) return;
|
|
4329
|
+
const model = getTextModel(prose);
|
|
4330
|
+
const rect = range.getBoundingClientRect();
|
|
4331
|
+
const box = prose.getBoundingClientRect();
|
|
4332
|
+
const modelText = model.text;
|
|
4333
|
+
const idx = locateInModel(model, text, void 0)?.start ?? modelText.indexOf(text);
|
|
4334
|
+
const surrounding = idx >= 0 ? modelText.slice(Math.max(0, idx - 30), idx + text.length + 30) : text;
|
|
4335
|
+
setQuoteBtn({
|
|
4336
|
+
x: Math.min(Math.max(rect.left - box.left + rect.width / 2, 90), Math.max(box.width - 90, 90)),
|
|
4337
|
+
y: rect.top - box.top - 38,
|
|
4338
|
+
text,
|
|
4339
|
+
surrounding
|
|
4340
|
+
});
|
|
4341
|
+
};
|
|
4342
|
+
(0, react.useEffect)(() => {
|
|
4343
|
+
const SETTLE = 250;
|
|
4344
|
+
let settleTimer = null;
|
|
4345
|
+
let hideTimer = null;
|
|
4346
|
+
let gesture = false;
|
|
4347
|
+
const selectionHasText = () => (window.getSelection()?.toString().trim().length ?? 0) >= 2;
|
|
4348
|
+
const onChange = () => {
|
|
4349
|
+
if (selectionHasText()) {
|
|
4350
|
+
if (hideTimer !== null) {
|
|
4351
|
+
clearTimeout(hideTimer);
|
|
4352
|
+
hideTimer = null;
|
|
4353
|
+
}
|
|
4354
|
+
setQuoteBtn((cur) => cur === null ? cur : null);
|
|
4355
|
+
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
4356
|
+
settleTimer = setTimeout(() => {
|
|
4357
|
+
settleTimer = null;
|
|
4358
|
+
if (!gesture && selectionHasText()) evaluateSelection();
|
|
4359
|
+
}, SETTLE);
|
|
4360
|
+
} else if (hideTimer === null) hideTimer = setTimeout(() => {
|
|
4361
|
+
setQuoteBtn((cur) => cur === null ? cur : null);
|
|
4362
|
+
}, 250);
|
|
4363
|
+
};
|
|
4364
|
+
const onDown = (e) => {
|
|
4365
|
+
if (e.target?.closest?.("[data-lks-quote-btn]") !== null) return;
|
|
4366
|
+
gesture = true;
|
|
4367
|
+
};
|
|
4368
|
+
const onUp = () => {
|
|
4369
|
+
gesture = false;
|
|
4370
|
+
if (settleTimer !== null) {
|
|
4371
|
+
clearTimeout(settleTimer);
|
|
4372
|
+
settleTimer = null;
|
|
4373
|
+
}
|
|
4374
|
+
if (hideTimer === null && selectionHasText()) evaluateSelection();
|
|
4375
|
+
};
|
|
4376
|
+
document.addEventListener("selectionchange", onChange);
|
|
4377
|
+
window.addEventListener("pointerdown", onDown, true);
|
|
4378
|
+
window.addEventListener("pointerup", onUp, true);
|
|
4379
|
+
window.addEventListener("pointercancel", onUp, true);
|
|
4380
|
+
return () => {
|
|
4381
|
+
document.removeEventListener("selectionchange", onChange);
|
|
4382
|
+
window.removeEventListener("pointerdown", onDown, true);
|
|
4383
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
4384
|
+
window.removeEventListener("pointercancel", onUp, true);
|
|
4385
|
+
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
4386
|
+
if (hideTimer !== null) clearTimeout(hideTimer);
|
|
4387
|
+
};
|
|
4388
|
+
}, []);
|
|
4389
|
+
const [enhanceTick, setEnhanceTick] = (0, react.useState)(0);
|
|
4390
|
+
const [rateBusy, setRateBusy] = (0, react.useState)(false);
|
|
2881
4391
|
const startReading = () => {
|
|
2882
4392
|
if (lesson === null || lesson.speechText.trim() === "") return;
|
|
4393
|
+
companionEvent("talk-start");
|
|
2883
4394
|
readCtl.current?.stop();
|
|
2884
4395
|
const sentences = speechSentencesOf(lesson.speechText);
|
|
2885
4396
|
const voice = storedTtsVoice();
|
|
@@ -2960,6 +4471,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2960
4471
|
});
|
|
2961
4472
|
};
|
|
2962
4473
|
const stopReading = () => {
|
|
4474
|
+
companionEvent("talk-end");
|
|
2963
4475
|
readCtl.current?.stop();
|
|
2964
4476
|
setRead(null);
|
|
2965
4477
|
};
|
|
@@ -2980,7 +4492,10 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
2980
4492
|
]);
|
|
2981
4493
|
(0, react.useEffect)(() => {
|
|
2982
4494
|
if (tab !== "teach" || proseRef.current === null || lesson === null) return;
|
|
2983
|
-
enhanceRendered(proseRef.current).catch(() => {})
|
|
4495
|
+
enhanceRendered(proseRef.current).catch(() => {}).finally(() => {
|
|
4496
|
+
applyHighlights(proseRef.current, recordQuotes);
|
|
4497
|
+
setEnhanceTick(Date.now());
|
|
4498
|
+
});
|
|
2984
4499
|
}, [tab, lesson?.html]);
|
|
2985
4500
|
const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: "lks14-notebody" }, (0, react.createElement)("div", { className: "lks14-lessonhead" }, (0, react.createElement)("h2", null, lesson.title), (0, react.createElement)("div", { className: "lks14-meta" }, `${lesson.courseTitle} · ${statusTitle("study", lesson.status)}` + (lesson.masteryPct === null ? "" : ` · ${tr("bb.mastery", { pct: lesson.masteryPct })}`))), (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
|
|
2986
4501
|
className: `lks14-viewtab${tab === "teach" ? " on" : ""}`,
|
|
@@ -3000,8 +4515,34 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
3000
4515
|
"aria-pressed": String(tab === "notes"),
|
|
3001
4516
|
onClick: () => {
|
|
3002
4517
|
setTab("notes");
|
|
4518
|
+
if (lesson !== null && unseen.length > 0) {
|
|
4519
|
+
markArtifactsSeen(lesson.lessonId, unseen);
|
|
4520
|
+
setUnseenTick(Date.now());
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
}, tr("bb.notes"), unseen.length > 0 ? (0, react.createElement)("span", { className: "lks-viewtab-badge" }, String(unseen.length)) : null)), tab === "teach" ? (0, react.createElement)("div", null, lesson.due ? (0, react.createElement)("div", {
|
|
4524
|
+
className: "lks-ratecard",
|
|
4525
|
+
"data-lks-rate": lesson.lessonId
|
|
4526
|
+
}, (0, react.createElement)("div", { className: "lks-ratecard-title" }, tr("review.rate.title")), (0, react.createElement)("div", { className: "lks-ratecard-opts" }, ...[
|
|
4527
|
+
["1", "review.again"],
|
|
4528
|
+
["4", "review.remembered"],
|
|
4529
|
+
["5", "review.mastered"]
|
|
4530
|
+
].map(([q, key]) => (0, react.createElement)("button", {
|
|
4531
|
+
key: q,
|
|
4532
|
+
className: `lks-ratecard-opt${q === "1" ? " again" : q === "5" ? " best" : ""}`,
|
|
4533
|
+
disabled: rateBusy,
|
|
4534
|
+
onClick: () => {
|
|
4535
|
+
setRateBusy(true);
|
|
4536
|
+
recordReview(lesson.lessonId, Number(q)).then(() => {
|
|
4537
|
+
companionEvent(Number(q) >= 4 ? "celebrate" : "encourage");
|
|
4538
|
+
showStudyToast(Number(q) >= 4 ? tr("review.done.good", { days: 1 }) : tr("review.done.again"), { severity: Number(q) >= 4 ? "success" : "warning" });
|
|
4539
|
+
setRateBusy(false);
|
|
4540
|
+
}).catch((err) => {
|
|
4541
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
4542
|
+
setRateBusy(false);
|
|
4543
|
+
});
|
|
3003
4544
|
}
|
|
3004
|
-
}, tr(
|
|
4545
|
+
}, tr(key))))) : null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
|
|
3005
4546
|
className: "lks-btn ghost",
|
|
3006
4547
|
style: {
|
|
3007
4548
|
padding: "3px 8px",
|
|
@@ -3029,14 +4570,49 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
3029
4570
|
},
|
|
3030
4571
|
title: tr("read.stop"),
|
|
3031
4572
|
onClick: stopReading
|
|
3032
|
-
}, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null, read !== null && read.degraded ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, tr("read.engine.system")) : null), (0, react.createElement)("div", {
|
|
4573
|
+
}, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null, read !== null && read.degraded ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, tr("read.engine.system")) : null), (0, react.createElement)("div", { className: "lks14-prosewrap" }, (0, react.createElement)("div", {
|
|
3033
4574
|
className: "lks14-prose",
|
|
3034
4575
|
ref: proseRef,
|
|
3035
4576
|
dangerouslySetInnerHTML: { __html: lesson.html }
|
|
3036
|
-
})
|
|
4577
|
+
}), quoteBtn !== null ? (0, react.createElement)("div", {
|
|
4578
|
+
className: "lks-quote-btn",
|
|
4579
|
+
"data-lks-quote-btn": "",
|
|
4580
|
+
style: {
|
|
4581
|
+
left: `${quoteBtn.x}px`,
|
|
4582
|
+
top: `${quoteBtn.y}px`
|
|
4583
|
+
}
|
|
4584
|
+
}, (0, react.createElement)("button", { onClick: () => {
|
|
4585
|
+
send(tr("note.quote.template", { text: quoteBtn.text.length > 200 ? `${quoteBtn.text.slice(0, 200)}…` : quoteBtn.text }));
|
|
4586
|
+
setQuoteBtn(null);
|
|
4587
|
+
window.getSelection()?.removeAllRanges();
|
|
4588
|
+
} }, tr("note.quote.ask")), (0, react.createElement)("button", { onClick: () => {
|
|
4589
|
+
if (lesson !== null) {
|
|
4590
|
+
addUserNote(lesson.lessonId, quoteBtn.text).catch((err) => {
|
|
4591
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
4592
|
+
});
|
|
4593
|
+
applyHighlights(proseRef.current, [quoteBtn.text]);
|
|
4594
|
+
showStudyToast(tr("note.saved"), { severity: "success" });
|
|
4595
|
+
}
|
|
4596
|
+
setQuoteBtn(null);
|
|
4597
|
+
window.getSelection()?.removeAllRanges();
|
|
4598
|
+
} }, tr("note.quote.save"))) : null), latestHeavy !== null ? (0, react.createElement)("div", { className: "lks-acard-stage" }, (0, react.createElement)(ArtifactCard, {
|
|
4599
|
+
artifact: latestHeavy,
|
|
4600
|
+
send: () => {}
|
|
4601
|
+
})) : null) : tab === "cmap" ? (0, react.createElement)("div", {
|
|
3037
4602
|
className: "lks14-prose",
|
|
3038
4603
|
ref: diagRef
|
|
3039
|
-
}) : (0, react.createElement)("div", { className: "lks14-zones" },
|
|
4604
|
+
}) : (0, react.createElement)("div", { className: "lks14-zones" }, (() => {
|
|
4605
|
+
const understand = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
|
|
4606
|
+
if (understand.length === 0) return null;
|
|
4607
|
+
return (0, react.createElement)("div", {
|
|
4608
|
+
key: "artifacts",
|
|
4609
|
+
className: "lks14-zone"
|
|
4610
|
+
}, (0, react.createElement)("div", { className: "lks14-zoneh" }, tr("zone.artifacts")), ...understand.map((a) => (0, react.createElement)(ArtifactCard, {
|
|
4611
|
+
key: a.id,
|
|
4612
|
+
artifact: a,
|
|
4613
|
+
send: () => {}
|
|
4614
|
+
})));
|
|
4615
|
+
})(), lesson.notes.length === 0 ? (0, react.createElement)("div", {
|
|
3040
4616
|
className: "lks14-empty",
|
|
3041
4617
|
style: { padding: "16px 0" }
|
|
3042
4618
|
}, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
|
|
@@ -3317,6 +4893,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
3317
4893
|
const { data, setMode, activate } = useStudy();
|
|
3318
4894
|
const [error, setError] = (0, react.useState)(null);
|
|
3319
4895
|
const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
|
|
4896
|
+
const [companion, setCompanion] = (0, react.useState)(storedCompanionForm);
|
|
3320
4897
|
const fire = (action) => {
|
|
3321
4898
|
action.then(() => {
|
|
3322
4899
|
setError(null);
|
|
@@ -3337,7 +4914,17 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
|
|
|
3337
4914
|
onClick: () => {
|
|
3338
4915
|
if (data !== null) fire(activate(!data.active));
|
|
3339
4916
|
}
|
|
3340
|
-
}, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.
|
|
4917
|
+
}, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.companion")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.companion.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
|
|
4918
|
+
className: "lks-set-select",
|
|
4919
|
+
value: companion,
|
|
4920
|
+
onChange: (e) => {
|
|
4921
|
+
storeCompanionForm(normalizeCompanionForm(e.target.value));
|
|
4922
|
+
setCompanion(normalizeCompanionForm(e.target.value));
|
|
4923
|
+
}
|
|
4924
|
+
}, ...COMPANION_FORMS.map((f) => (0, react.createElement)("option", {
|
|
4925
|
+
key: f.id,
|
|
4926
|
+
value: f.id
|
|
4927
|
+
}, tr(f.labelKey)))))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
|
|
3341
4928
|
className: "lks-set-select",
|
|
3342
4929
|
value: voice,
|
|
3343
4930
|
onChange: (e) => {
|