dsh-plugin-lookatstudy 0.25.4 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +667 -49
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +739 -64
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -401,6 +401,19 @@ window.__ModuleLoader__.load({
|
|
|
401
401
|
registration; the span class is the no-Highlight-API fallback) */
|
|
402
402
|
::highlight(lks-reading){background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong)}
|
|
403
403
|
.lks-ui .lks14-reading-mark{background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong);border-radius:3px}
|
|
404
|
+
/* v0.35.1-alignment: persistent note highlights ride the same registration
|
|
405
|
+
route (zero DOM mutation) — same skin as the mark.lks-hl fallback */
|
|
406
|
+
.lks-ui ::highlight(lks-note-hl){background:rgb(var(--gold-rgb)/0.25);border-bottom:2px solid var(--gold)}
|
|
407
|
+
/* v0.37-alignment: the progression-boundary card + the rail pulse ring the
|
|
408
|
+
「指出下一课」 action arms (bot points, user drives — nothing auto-switches) */
|
|
409
|
+
.lks-ui .lks14-boundary{display:flex;align-items:center;gap:10px;margin:10px 20px 4px;padding:10px 12px;border:1px solid rgb(var(--gold-rgb)/0.5);background:rgb(var(--gold-rgb)/0.08);border-radius:10px;flex:none;color:var(--gold-dark)}
|
|
410
|
+
.lks-ui .lks14-boundary-text{flex:1;min-width:0}
|
|
411
|
+
.lks-ui .lks14-boundary-title{font-weight:700;font-size:13.5px;color:var(--ink-strong)}
|
|
412
|
+
.lks-ui .lks14-boundary-sub{font-size:12px;color:var(--ink-muted);margin-top:2px}
|
|
413
|
+
.lks-ui .lks14-boundary-x{flex:none;background:none;border:none;color:var(--ink-muted);cursor:pointer;font-size:13px;padding:4px 6px}
|
|
414
|
+
.lks-ui .lks14-boundary-x:hover{color:var(--ink-strong)}
|
|
415
|
+
.lks-ui .lks-lessorow.next-hint{outline:2px dashed var(--gold);outline-offset:1px;animation:lks-hint-pulse 1.6s ease-in-out infinite}
|
|
416
|
+
@keyframes lks-hint-pulse{0%,100%{outline-color:rgb(var(--gold-rgb)/0.95)}50%{outline-color:rgb(var(--gold-rgb)/0.25)}}
|
|
404
417
|
/* #10-followup: the reading column is height:auto (the note col scrolls), so
|
|
405
418
|
.lks14-board's height:100% resolved against a content-sized parent and the
|
|
406
419
|
stage collapsed to a ~90px sliver — the never-upscale contain fit then
|
|
@@ -1774,6 +1787,64 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
1774
1787
|
}));
|
|
1775
1788
|
}
|
|
1776
1789
|
//#endregion
|
|
1790
|
+
//#region src/client/stage-session.ts
|
|
1791
|
+
/** The plugin's private retention label (never the view owner's 'mainView'). */
|
|
1792
|
+
const STAGE_SOURCE = "lookatstudy";
|
|
1793
|
+
/** Which staging generation the host speaks ('retain' wins when both exist). */
|
|
1794
|
+
function stagingMode(sessions) {
|
|
1795
|
+
return typeof sessions.retain === "function" ? "retain" : "open";
|
|
1796
|
+
}
|
|
1797
|
+
let held = null;
|
|
1798
|
+
/**
|
|
1799
|
+
* Stage `sessionId` for observation + addressability.
|
|
1800
|
+
* alpha: holds the plugin's reference. rc: `open()` navigation. Throws
|
|
1801
|
+
* whatever the host face throws — callers keep their existing catch policy.
|
|
1802
|
+
*/
|
|
1803
|
+
function stageSession(sessions, sessionId) {
|
|
1804
|
+
if (stagingMode(sessions) === "retain") {
|
|
1805
|
+
if (held !== null && held.sessionId === sessionId) return;
|
|
1806
|
+
const previous = held;
|
|
1807
|
+
held = sessions.retain(sessionId, { source: STAGE_SOURCE });
|
|
1808
|
+
previous?.release();
|
|
1809
|
+
} else sessions.open?.(sessionId);
|
|
1810
|
+
}
|
|
1811
|
+
/** Release the held alpha reference (plugin dispose — across open/close
|
|
1812
|
+
* toggles the reference stays held, mirroring rc's sticky current). */
|
|
1813
|
+
function unstageSession() {
|
|
1814
|
+
held?.release();
|
|
1815
|
+
held = null;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Hold a TRANSIENT alpha reference for an effect's lifetime so scope/binding
|
|
1819
|
+
* resolve for an un-staged session (E1/E6 projections, thread rename).
|
|
1820
|
+
* rc: a no-op closer — listed ids are addressable there without help, and
|
|
1821
|
+
* calling open() from inside an effect would trip the rc hand-back.
|
|
1822
|
+
* alpha: undefined only when the retain is refused (treat as feature-absent).
|
|
1823
|
+
*/
|
|
1824
|
+
function acquireStageReference(sessions, sessionId) {
|
|
1825
|
+
if (sessions.retain === void 0) return () => {};
|
|
1826
|
+
let ref;
|
|
1827
|
+
try {
|
|
1828
|
+
ref = sessions.retain(sessionId, { source: STAGE_SOURCE });
|
|
1829
|
+
} catch {
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
return () => {
|
|
1833
|
+
ref?.release();
|
|
1834
|
+
ref = void 0;
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Derive the host main-view selection from a list snapshot the way
|
|
1839
|
+
* ui-workspace does: alpha removed `list.current`, and the view owner's
|
|
1840
|
+
* `mainView` retention is the only public trace of the main selection.
|
|
1841
|
+
* Pure; rc snapshots carry no `retainedBy` and read as no selection.
|
|
1842
|
+
*/
|
|
1843
|
+
function mainSelectionOf(byId) {
|
|
1844
|
+
if (byId === void 0) return void 0;
|
|
1845
|
+
for (const entry of Object.entries(byId)) if ((entry[1]?.retainedBy?.mainView ?? 0) > 0) return entry[0];
|
|
1846
|
+
}
|
|
1847
|
+
//#endregion
|
|
1777
1848
|
//#region src/client/data.ts
|
|
1778
1849
|
/**
|
|
1779
1850
|
* Shared client-side study store: one module-level poller over the host
|
|
@@ -1990,6 +2061,37 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
1990
2061
|
});
|
|
1991
2062
|
this.refresh();
|
|
1992
2063
|
}
|
|
2064
|
+
/** Upstream v0.36: save the learner's OWN profile edit (the human path —
|
|
2065
|
+
* this is also what marks older AI suggestions stale). */
|
|
2066
|
+
async saveProfile(patch) {
|
|
2067
|
+
await fetchJson("/lookatstudy/api/profile", {
|
|
2068
|
+
method: "POST",
|
|
2069
|
+
headers: { "content-type": "application/json" },
|
|
2070
|
+
body: JSON.stringify(patch)
|
|
2071
|
+
});
|
|
2072
|
+
this.refresh();
|
|
2073
|
+
}
|
|
2074
|
+
/** Accept or ignore one AI profile suggestion (the settings-page consumer). */
|
|
2075
|
+
async resolveProfileProposal(id, accept) {
|
|
2076
|
+
await fetchJson("/lookatstudy/api/profile/resolve", {
|
|
2077
|
+
method: "POST",
|
|
2078
|
+
headers: { "content-type": "application/json" },
|
|
2079
|
+
body: JSON.stringify({
|
|
2080
|
+
id,
|
|
2081
|
+
accept
|
|
2082
|
+
})
|
|
2083
|
+
});
|
|
2084
|
+
this.refresh();
|
|
2085
|
+
}
|
|
2086
|
+
/** Anti-farming (upstream v0.35): a locally-judged practice card is human
|
|
2087
|
+
* grading — report it so the tutor-only mastery cap lifts on the lesson. */
|
|
2088
|
+
async reportPracticeGraded(lessonId) {
|
|
2089
|
+
await fetchJson("/lookatstudy/api/practice-graded", {
|
|
2090
|
+
method: "POST",
|
|
2091
|
+
headers: { "content-type": "application/json" },
|
|
2092
|
+
body: JSON.stringify({ lessonId })
|
|
2093
|
+
}).catch(() => void 0);
|
|
2094
|
+
}
|
|
1993
2095
|
/** Delete one notebook entry from a lesson's Cornell zones. */
|
|
1994
2096
|
async deleteNote(lessonId, noteId) {
|
|
1995
2097
|
await fetchJson("/lookatstudy/api/note/delete", {
|
|
@@ -2159,6 +2261,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2159
2261
|
setFocus: studyStore.setFocus.bind(studyStore),
|
|
2160
2262
|
searchLessons: studyStore.searchLessons.bind(studyStore),
|
|
2161
2263
|
tts: studyStore.tts.bind(studyStore),
|
|
2264
|
+
saveProfile: studyStore.saveProfile.bind(studyStore),
|
|
2265
|
+
resolveProfileProposal: studyStore.resolveProfileProposal.bind(studyStore),
|
|
2266
|
+
reportPracticeGraded: studyStore.reportPracticeGraded.bind(studyStore),
|
|
2162
2267
|
setCourseScope: studyStore.setCourseScope.bind(studyStore),
|
|
2163
2268
|
deleteCourse: studyStore.deleteCourse.bind(studyStore),
|
|
2164
2269
|
deleteNote: studyStore.deleteNote.bind(studyStore),
|
|
@@ -2255,6 +2360,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2255
2360
|
"proposal.banner": "《{lesson}》可以提前毕业了",
|
|
2256
2361
|
"proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
|
|
2257
2362
|
"proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
|
|
2363
|
+
"boundary.done": "本课完成,皇冠到手!",
|
|
2364
|
+
"boundary.course.done": "整门课程完成!",
|
|
2365
|
+
"boundary.next.sub": "下一课是《{title}》——在课程栏点它进入。",
|
|
2366
|
+
"boundary.thread.sub": "下一课是《{title}》——点开始,就在这条线上接着讲。",
|
|
2367
|
+
"boundary.thread.point": "开始下一课",
|
|
2368
|
+
"boundary.thread.send": "这一课完成了,我们开始下一课《{title}》吧。",
|
|
2369
|
+
"boundary.last.sub": "全部课时都已掌握,复习会按计划回来。",
|
|
2370
|
+
"boundary.point": "在课程栏指出下一课",
|
|
2371
|
+
"boundary.dismiss": "收起",
|
|
2258
2372
|
"tutor.thinking": "导师思考中…",
|
|
2259
2373
|
"header.xp": "经验值 {xp}",
|
|
2260
2374
|
"header.streak": "连续学习天数",
|
|
@@ -2489,7 +2603,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2489
2603
|
"settings.stats.courses": "课程 {count} 个",
|
|
2490
2604
|
"settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
|
|
2491
2605
|
"settings.stats.today": "今日 {xp}/{goal}",
|
|
2492
|
-
"settings.stats.streak": "连续 {days} 天
|
|
2606
|
+
"settings.stats.streak": "连续 {days} 天 · 最长 {best} 天 · 冻结 {freeze}",
|
|
2607
|
+
"settings.stats.mastered": "已掌握 {count} 课 · 待复习 {due} 课",
|
|
2608
|
+
"settings.profile": "学习者画像",
|
|
2609
|
+
"settings.profile.hint": "只属于你的背景事实,用来调整怎么教——不进任何评分。AI 的建议也在这里处理,不打断对话。",
|
|
2610
|
+
"settings.profile.name": "称呼",
|
|
2611
|
+
"settings.profile.mbti": "MBTI(快捷入口,自动展开为风格四维)",
|
|
2612
|
+
"settings.profile.interests": "兴趣点(顿号/逗号分隔,最多 8 个)",
|
|
2613
|
+
"settings.profile.interests.ph": "例如:天文、做菜、悬疑小说",
|
|
2614
|
+
"settings.profile.note": "想对导师说的话",
|
|
2615
|
+
"settings.profile.motive": "学习动机(只有你自己能设置)",
|
|
2616
|
+
"settings.profile.unset": "未设置",
|
|
2617
|
+
"settings.profile.save": "保存画像",
|
|
2618
|
+
"settings.profile.private": "画像只存本地 state.json,不会上传。",
|
|
2619
|
+
"settings.style.start": "讲解起点",
|
|
2620
|
+
"settings.style.analogy": "先具体实例建直觉",
|
|
2621
|
+
"settings.style.framework": "先框架后细节",
|
|
2622
|
+
"settings.style.interaction": "互动密度",
|
|
2623
|
+
"settings.style.dialogue": "边讲边问",
|
|
2624
|
+
"settings.style.lecture": "讲完一节再答疑",
|
|
2625
|
+
"settings.style.feedback": "反馈风格",
|
|
2626
|
+
"settings.style.direct": "直接纠错",
|
|
2627
|
+
"settings.style.encouraging": "先肯定再指正",
|
|
2628
|
+
"settings.style.pacing": "节奏",
|
|
2629
|
+
"settings.style.sequential": "按顺序推进",
|
|
2630
|
+
"settings.style.exploratory": "允许跳着学",
|
|
2631
|
+
"settings.suggest.head": "AI 建议的",
|
|
2632
|
+
"settings.suggest.apply": "采纳",
|
|
2633
|
+
"settings.suggest.ignore": "忽略",
|
|
2493
2634
|
"settings.about": "关于",
|
|
2494
2635
|
"settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
|
|
2495
2636
|
"settings.stateFile": "状态文件",
|
|
@@ -2626,6 +2767,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2626
2767
|
"proposal.banner": "{lesson} is ready to graduate early",
|
|
2627
2768
|
"proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
|
|
2628
2769
|
"proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
|
|
2770
|
+
"boundary.done": "Lesson complete — crown earned!",
|
|
2771
|
+
"boundary.course.done": "Course complete!",
|
|
2772
|
+
"boundary.next.sub": "Next up: {title} — click it in the course rail to continue.",
|
|
2773
|
+
"boundary.thread.sub": "Next: {title} — continue right here in this thread.",
|
|
2774
|
+
"boundary.thread.point": "Start the next lesson",
|
|
2775
|
+
"boundary.thread.send": "This lesson is wrapped up — let's start the next one, {title}.",
|
|
2776
|
+
"boundary.last.sub": "Every lesson is mastered; reviews will come back on schedule.",
|
|
2777
|
+
"boundary.point": "Point at the next lesson",
|
|
2778
|
+
"boundary.dismiss": "Dismiss",
|
|
2629
2779
|
"tutor.thinking": "The tutor is thinking…",
|
|
2630
2780
|
"header.xp": "{xp} XP",
|
|
2631
2781
|
"header.streak": "Day streak",
|
|
@@ -2860,7 +3010,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2860
3010
|
"settings.stats.courses": "{count} courses",
|
|
2861
3011
|
"settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
|
|
2862
3012
|
"settings.stats.today": "Today {xp}/{goal}",
|
|
2863
|
-
"settings.stats.streak": "Streak {days}
|
|
3013
|
+
"settings.stats.streak": "Streak {days} days · best {best} · freezes {freeze}",
|
|
3014
|
+
"settings.stats.mastered": "{count} lessons mastered · {due} due",
|
|
3015
|
+
"settings.profile": "Learner profile",
|
|
3016
|
+
"settings.profile.hint": "Background facts only you can declare — they shape HOW you are taught, never any scoring. AI suggestions resolve here, never in the chat.",
|
|
3017
|
+
"settings.profile.name": "Name",
|
|
3018
|
+
"settings.profile.mbti": "MBTI (shortcut — expands into the style dims)",
|
|
3019
|
+
"settings.profile.interests": "Interests (comma-separated, max 8)",
|
|
3020
|
+
"settings.profile.interests.ph": "e.g. astronomy, cooking, mystery novels",
|
|
3021
|
+
"settings.profile.note": "Note to the tutor",
|
|
3022
|
+
"settings.profile.motive": "Learning motive (only you can set this)",
|
|
3023
|
+
"settings.profile.unset": "Not set",
|
|
3024
|
+
"settings.profile.save": "Save profile",
|
|
3025
|
+
"settings.profile.private": "Stored in your local state.json only — never uploaded.",
|
|
3026
|
+
"settings.style.start": "Explanation start",
|
|
3027
|
+
"settings.style.analogy": "Concrete examples first",
|
|
3028
|
+
"settings.style.framework": "Framework first",
|
|
3029
|
+
"settings.style.interaction": "Interaction density",
|
|
3030
|
+
"settings.style.dialogue": "Questions as we go",
|
|
3031
|
+
"settings.style.lecture": "Lecture first, Q&A after",
|
|
3032
|
+
"settings.style.feedback": "Feedback style",
|
|
3033
|
+
"settings.style.direct": "Direct corrections",
|
|
3034
|
+
"settings.style.encouraging": "Encouraging corrections",
|
|
3035
|
+
"settings.style.pacing": "Pacing",
|
|
3036
|
+
"settings.style.sequential": "Sequential",
|
|
3037
|
+
"settings.style.exploratory": "Exploratory",
|
|
3038
|
+
"settings.suggest.head": "AI suggested",
|
|
3039
|
+
"settings.suggest.apply": "Accept",
|
|
3040
|
+
"settings.suggest.ignore": "Ignore",
|
|
2864
3041
|
"settings.about": "About",
|
|
2865
3042
|
"settings.about.hint": "The version of the running build — click to see its release notes.",
|
|
2866
3043
|
"settings.stateFile": "State file",
|
|
@@ -4764,7 +4941,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
4764
4941
|
nodes
|
|
4765
4942
|
};
|
|
4766
4943
|
}
|
|
4767
|
-
function offsetsToRange(model, start, end) {
|
|
4944
|
+
function offsetsToRange$1(model, start, end) {
|
|
4768
4945
|
const range = document.createRange();
|
|
4769
4946
|
let setStart = false;
|
|
4770
4947
|
let setEnd = false;
|
|
@@ -4823,7 +5000,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
4823
5000
|
if (m === null && from > 0) m = matchSentenceAligned(model.text, trimmed, 0);
|
|
4824
5001
|
if (m === null) return null;
|
|
4825
5002
|
readingCursors.set(container, m[1]);
|
|
4826
|
-
const range = offsetsToRange(model, m[0], m[1]);
|
|
5003
|
+
const range = offsetsToRange$1(model, m[0], m[1]);
|
|
4827
5004
|
if (range === null) return null;
|
|
4828
5005
|
const cssLike = CSS;
|
|
4829
5006
|
if (typeof Highlight !== "undefined" && cssLike.highlights !== void 0) try {
|
|
@@ -5249,6 +5426,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
5249
5426
|
};
|
|
5250
5427
|
}
|
|
5251
5428
|
/**
|
|
5429
|
+
* Tool-error visibility (upstream v0.37.1 toolErrorVisibility, part-accumulator):
|
|
5430
|
+
* an error tool row that is SUPERSEDED by a later same-tool call within the
|
|
5431
|
+
* same turn (no learner message in between) is dropped — weak models fail a
|
|
5432
|
+
* zod-validated input on the first try and succeed on the retry (observed
|
|
5433
|
+
* live: the quiz error block flashed, then the quiz card landed), and the
|
|
5434
|
+
* stale transient failure is pure noise. A real failure — no successor —
|
|
5435
|
+
* stays visible; nothing is silently swallowed. Pure.
|
|
5436
|
+
*/
|
|
5437
|
+
function hideSupersededToolErrors(rows) {
|
|
5438
|
+
const out = [];
|
|
5439
|
+
for (let i = 0; i < rows.length; i++) {
|
|
5440
|
+
const row = rows[i];
|
|
5441
|
+
if (row.role === "tool" && row.toolState === "error") {
|
|
5442
|
+
let superseded = false;
|
|
5443
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
5444
|
+
const later = rows[j];
|
|
5445
|
+
if (later.role === "user") break;
|
|
5446
|
+
if (later.role === "tool" && later.text === row.text && (later.toolState === "loading" || later.toolState === "done")) {
|
|
5447
|
+
superseded = true;
|
|
5448
|
+
break;
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
if (superseded) continue;
|
|
5452
|
+
}
|
|
5453
|
+
out.push(row);
|
|
5454
|
+
}
|
|
5455
|
+
return out;
|
|
5456
|
+
}
|
|
5457
|
+
/**
|
|
5252
5458
|
* Fold one event-window snapshot into ordered chat rows. The transient
|
|
5253
5459
|
* `text-delta` chunks of the most recent attempt accumulate into a single
|
|
5254
5460
|
* streaming row (the durable `assistant/message` replaces it on settlement).
|
|
@@ -5369,7 +5575,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
5369
5575
|
running: true
|
|
5370
5576
|
});
|
|
5371
5577
|
}
|
|
5372
|
-
return rows;
|
|
5578
|
+
return hideSupersededToolErrors(rows);
|
|
5373
5579
|
}
|
|
5374
5580
|
/**
|
|
5375
5581
|
* Parse an ask_user_question call's arguments into the answerable row payload
|
|
@@ -6058,6 +6264,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6058
6264
|
if (!showScore || !allAnswered || hookSent) return;
|
|
6059
6265
|
setHookSent(true);
|
|
6060
6266
|
onFinished?.(score.correct === score.total);
|
|
6267
|
+
studyStore.reportPracticeGraded(lessonId);
|
|
6061
6268
|
const next = {
|
|
6062
6269
|
answers: [...progress.answers],
|
|
6063
6270
|
done: true
|
|
@@ -6924,11 +7131,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6924
7131
|
nodes
|
|
6925
7132
|
};
|
|
6926
7133
|
}
|
|
6927
|
-
/** The mark class applied to persisted highlights. */
|
|
7134
|
+
/** The mark class applied to persisted highlights (DOM fallback channel only). */
|
|
6928
7135
|
const HIGHLIGHT_MARK = "lks-hl";
|
|
6929
7136
|
/**
|
|
7137
|
+
* Persistent-note highlight registry name — the CSS Custom Highlight API
|
|
7138
|
+
* channel (upstream v0.35.1 root fix): the old scheme split text nodes and
|
|
7139
|
+
* inserted <mark>, rewriting DOM a framework may reconcile over — any later
|
|
7140
|
+
* re-render could throw a DOMException into the error boundary. The registry
|
|
7141
|
+
* channel registers Ranges in CSS.highlights and touches ZERO DOM nodes.
|
|
7142
|
+
* Styled by the `::highlight(lks-note-hl)` rule in the upstream skin.
|
|
7143
|
+
*/
|
|
7144
|
+
const NOTE_HIGHLIGHT_NAME = "lks-note-hl";
|
|
7145
|
+
/** Whether the registration channel exists (Highlight ctor + CSS.highlights). */
|
|
7146
|
+
function supportsHighlightMarks() {
|
|
7147
|
+
return typeof Highlight !== "undefined" && !!CSS.highlights;
|
|
7148
|
+
}
|
|
7149
|
+
/** Live note ranges: quote → Range. CSS.highlights is a document-level
|
|
7150
|
+
* singleton, so every container shares one Highlight; dead ranges (nodes
|
|
7151
|
+
* detached by a re-render of the source) are pruned on rebuild so the map
|
|
7152
|
+
* cannot grow without bound. */
|
|
7153
|
+
const liveNoteRanges = /* @__PURE__ */ new Map();
|
|
7154
|
+
const noteRangeOwners = /* @__PURE__ */ new WeakMap();
|
|
7155
|
+
function rebuildNoteHighlight() {
|
|
7156
|
+
const cssLike = CSS;
|
|
7157
|
+
for (const [quote, r] of liveNoteRanges) if (!r.startContainer.isConnected) liveNoteRanges.delete(quote);
|
|
7158
|
+
try {
|
|
7159
|
+
if (liveNoteRanges.size === 0) cssLike.highlights?.delete(NOTE_HIGHLIGHT_NAME);
|
|
7160
|
+
else cssLike.highlights?.set(NOTE_HIGHLIGHT_NAME, new Highlight(...liveNoteRanges.values()));
|
|
7161
|
+
} catch {}
|
|
7162
|
+
}
|
|
7163
|
+
/**
|
|
6930
7164
|
* Apply one highlight range to the live DOM: split the boundary text nodes
|
|
6931
7165
|
* and wrap the covering segments in marks. Returns the created marks.
|
|
7166
|
+
* (The DOM fallback channel — only reached when the Highlight API is absent.)
|
|
6932
7167
|
*/
|
|
6933
7168
|
function wrapRange(model, start, end) {
|
|
6934
7169
|
const marks = [];
|
|
@@ -6949,12 +7184,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6949
7184
|
}
|
|
6950
7185
|
/**
|
|
6951
7186
|
* Render the persisted highlights for one lesson: each record-zone note with a
|
|
6952
|
-
* quote anchors a
|
|
6953
|
-
*
|
|
6954
|
-
*
|
|
7187
|
+
* quote anchors a highlight. Registry channel first (zero DOM mutation — a
|
|
7188
|
+
* framework re-render can never hit a foreign node); the mark-wrapping
|
|
7189
|
+
* fallback only runs on engines without CSS.highlights. Idempotent per call —
|
|
7190
|
+
* call on a freshly rendered container; re-entry replaces this container's
|
|
7191
|
+
* registered ranges first.
|
|
7192
|
+
* @returns how many notes got at least one highlight.
|
|
6955
7193
|
*/
|
|
6956
7194
|
function applyHighlights(container, quotes) {
|
|
6957
7195
|
let applied = 0;
|
|
7196
|
+
if (supportsHighlightMarks()) {
|
|
7197
|
+
const owned = noteRangeOwners.get(container);
|
|
7198
|
+
if (owned !== void 0) for (const quote of owned) liveNoteRanges.delete(quote);
|
|
7199
|
+
const hits = /* @__PURE__ */ new Map();
|
|
7200
|
+
for (const quote of quotes) {
|
|
7201
|
+
const trimmed = quote.trim();
|
|
7202
|
+
if (trimmed.length < 2) continue;
|
|
7203
|
+
const model = getTextModel(container);
|
|
7204
|
+
const located = locateInModel(model, trimmed, void 0);
|
|
7205
|
+
if (located === null) continue;
|
|
7206
|
+
const range = offsetsToRange(model, located.start, located.end);
|
|
7207
|
+
if (range === null) continue;
|
|
7208
|
+
hits.set(trimmed, range);
|
|
7209
|
+
liveNoteRanges.set(trimmed, range);
|
|
7210
|
+
applied += 1;
|
|
7211
|
+
}
|
|
7212
|
+
noteRangeOwners.set(container, new Set(hits.keys()));
|
|
7213
|
+
rebuildNoteHighlight();
|
|
7214
|
+
return applied;
|
|
7215
|
+
}
|
|
6958
7216
|
for (const quote of quotes) {
|
|
6959
7217
|
const trimmed = quote.trim();
|
|
6960
7218
|
if (trimmed.length < 2) continue;
|
|
@@ -6965,6 +7223,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6965
7223
|
}
|
|
6966
7224
|
return applied;
|
|
6967
7225
|
}
|
|
7226
|
+
/** Build one Range across the model's text nodes (null when the browser
|
|
7227
|
+
* refuses — e.g. a detached node between model build and call). */
|
|
7228
|
+
function offsetsToRange(model, start, end) {
|
|
7229
|
+
const segments = planSegments(model.nodes, start, end);
|
|
7230
|
+
if (segments.length === 0) return null;
|
|
7231
|
+
const first = segments[0];
|
|
7232
|
+
const last = segments[segments.length - 1];
|
|
7233
|
+
try {
|
|
7234
|
+
const range = new Range();
|
|
7235
|
+
range.setStart(model.nodes[first.index].node, first.localStart);
|
|
7236
|
+
range.setEnd(model.nodes[last.index].node, last.localEnd);
|
|
7237
|
+
return range;
|
|
7238
|
+
} catch {
|
|
7239
|
+
return null;
|
|
7240
|
+
}
|
|
7241
|
+
}
|
|
6968
7242
|
//#endregion
|
|
6969
7243
|
//#region src/client/maprail.tsx
|
|
6970
7244
|
/**
|
|
@@ -6995,17 +7269,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6995
7269
|
if (lesson.status === "in_progress") return lesson.masteryPct ?? 0;
|
|
6996
7270
|
return null;
|
|
6997
7271
|
}
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7272
|
+
function nextStudyAfterView(courses, lessonId) {
|
|
7273
|
+
for (const course of courses) for (let si = 0; si < course.sections.length; si++) {
|
|
7274
|
+
const lessons = course.sections[si].lessons;
|
|
7275
|
+
const li = lessons.findIndex((l) => l.id === lessonId);
|
|
7276
|
+
if (li < 0) continue;
|
|
7277
|
+
for (let j = li + 1; j < lessons.length; j++) if (lessons[j].kind === "study") return {
|
|
7278
|
+
id: lessons[j].id,
|
|
7279
|
+
title: lessons[j].title
|
|
7280
|
+
};
|
|
7281
|
+
for (let s = si + 1; s < course.sections.length; s++) {
|
|
7282
|
+
const first = course.sections[s].lessons.find((l) => l.kind === "study");
|
|
7283
|
+
if (first !== void 0) return {
|
|
7284
|
+
id: first.id,
|
|
7285
|
+
title: first.title
|
|
7286
|
+
};
|
|
7287
|
+
}
|
|
7288
|
+
return null;
|
|
7289
|
+
}
|
|
7290
|
+
return null;
|
|
7291
|
+
}
|
|
7004
7292
|
function sectionWorldOf(section) {
|
|
7005
7293
|
return section.lessons.length > 0 && section.lessons.every((l) => l.kind === "practice") ? "practice" : "study";
|
|
7006
7294
|
}
|
|
7007
7295
|
/** One section: a quiet header toggle + the lesson rows. */
|
|
7008
|
-
function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId }) {
|
|
7296
|
+
function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId, hintId }) {
|
|
7009
7297
|
const lessons = section.lessons;
|
|
7010
7298
|
const done = lessons.filter((l) => l.status === "mastered").length;
|
|
7011
7299
|
return (0, react.createElement)("section", { className: "lks-railsec" }, (0, react.createElement)("button", {
|
|
@@ -7022,7 +7310,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
7022
7310
|
return (0, react.createElement)("button", {
|
|
7023
7311
|
key: lesson.id,
|
|
7024
7312
|
type: "button",
|
|
7025
|
-
className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}`,
|
|
7313
|
+
className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}${hintId !== void 0 && hintId !== null && hintId === lesson.id ? " next-hint" : ""}`,
|
|
7026
7314
|
"data-node-id": lesson.id,
|
|
7027
7315
|
"aria-disabled": locked || void 0,
|
|
7028
7316
|
"data-tooltip": `${lesson.title} — ${stateWord}`,
|
|
@@ -7864,6 +8152,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
7864
8152
|
}
|
|
7865
8153
|
}
|
|
7866
8154
|
/** C8: the most recent decided proposal's badge payload (render order preserved). */
|
|
8155
|
+
/** Upstream v0.37 boundary card: lessons whose card the learner dismissed
|
|
8156
|
+
* (persisted so a remount does not re-nag about an already-mastered lesson). */
|
|
8157
|
+
const BOUNDARY_DISMISS_KEY = "lks-boundary-dismissed";
|
|
8158
|
+
function boundaryDismissedLessons() {
|
|
8159
|
+
try {
|
|
8160
|
+
const raw = JSON.parse(localStorage.getItem(BOUNDARY_DISMISS_KEY) ?? "[]");
|
|
8161
|
+
return new Set(Array.isArray(raw) ? raw.filter((s) => typeof s === "string") : []);
|
|
8162
|
+
} catch {
|
|
8163
|
+
return /* @__PURE__ */ new Set();
|
|
8164
|
+
}
|
|
8165
|
+
}
|
|
7867
8166
|
function decidedBadgeOf(decided) {
|
|
7868
8167
|
const entries = Object.values(decided);
|
|
7869
8168
|
return entries.length > 0 ? entries[entries.length - 1] : null;
|
|
@@ -8141,6 +8440,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8141
8440
|
const [draft, setDraft] = (0, react.useState)("");
|
|
8142
8441
|
const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
|
|
8143
8442
|
const lesson = data?.lesson ?? null;
|
|
8443
|
+
const [boundary, setBoundary] = (0, react.useState)(null);
|
|
8444
|
+
const [nextHint, setNextHint] = (0, react.useState)(null);
|
|
8445
|
+
const prevStatusRef = (0, react.useRef)(null);
|
|
8446
|
+
(0, react.useEffect)(() => {
|
|
8447
|
+
const status = lesson?.status ?? null;
|
|
8448
|
+
const prev = prevStatusRef.current;
|
|
8449
|
+
prevStatusRef.current = status;
|
|
8450
|
+
if (lesson === null || lesson.kind !== "study" || status !== "mastered") {
|
|
8451
|
+
setBoundary(null);
|
|
8452
|
+
return;
|
|
8453
|
+
}
|
|
8454
|
+
if (prev === "mastered") return;
|
|
8455
|
+
if (boundaryDismissedLessons().has(lesson.lessonId)) return;
|
|
8456
|
+
const next = data !== null ? nextStudyAfterView(data.courses, lesson.lessonId) : null;
|
|
8457
|
+
setBoundary({
|
|
8458
|
+
lessonId: lesson.lessonId,
|
|
8459
|
+
nextId: next?.id ?? null,
|
|
8460
|
+
nextTitle: next?.title ?? null,
|
|
8461
|
+
last: next === null
|
|
8462
|
+
});
|
|
8463
|
+
}, [lesson, data]);
|
|
8464
|
+
const dismissBoundary = (0, react.useCallback)(() => {
|
|
8465
|
+
if (boundary !== null) try {
|
|
8466
|
+
const set = boundaryDismissedLessons();
|
|
8467
|
+
set.add(boundary.lessonId);
|
|
8468
|
+
localStorage.setItem(BOUNDARY_DISMISS_KEY, JSON.stringify([...set]));
|
|
8469
|
+
} catch {}
|
|
8470
|
+
setBoundary(null);
|
|
8471
|
+
}, [boundary]);
|
|
8472
|
+
const pointAtNext = (0, react.useCallback)((nextId) => {
|
|
8473
|
+
setNextHint(nextId);
|
|
8474
|
+
setNarrowPane("rail");
|
|
8475
|
+
}, []);
|
|
8144
8476
|
const examLessonActive = lesson !== null && lesson.kind === "exam" && examOpen(data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId))?.lessons ?? []);
|
|
8145
8477
|
const localBound = (0, react.useRef)(null);
|
|
8146
8478
|
const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
|
|
@@ -8211,9 +8543,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8211
8543
|
}
|
|
8212
8544
|
const shell = PANEL_SHELL.current;
|
|
8213
8545
|
if (shell !== null) shell.suppressHandBack(() => {
|
|
8214
|
-
ctx.sessions
|
|
8546
|
+
stageSession(ctx.sessions, sessionId);
|
|
8215
8547
|
});
|
|
8216
|
-
else ctx.sessions
|
|
8548
|
+
else stageSession(ctx.sessions, sessionId);
|
|
8217
8549
|
const actx = ctx.sessions.scope(sessionId);
|
|
8218
8550
|
const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
|
|
8219
8551
|
if (face === void 0) throw new Error("import session is not addressable yet");
|
|
@@ -8256,6 +8588,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8256
8588
|
(0, react.useEffect)(() => {
|
|
8257
8589
|
setPressure(null);
|
|
8258
8590
|
if (boundId === null) return;
|
|
8591
|
+
const closeStage = acquireStageReference(ctx.sessions, boundId);
|
|
8259
8592
|
let disposed = false;
|
|
8260
8593
|
let off;
|
|
8261
8594
|
try {
|
|
@@ -8281,22 +8614,27 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8281
8614
|
}
|
|
8282
8615
|
};
|
|
8283
8616
|
read();
|
|
8284
|
-
off = proj.subscribe(
|
|
8617
|
+
off = proj.subscribe(() => {
|
|
8618
|
+
queueMicrotask(read);
|
|
8619
|
+
});
|
|
8285
8620
|
return () => {
|
|
8286
8621
|
disposed = true;
|
|
8287
8622
|
off?.();
|
|
8623
|
+
closeStage?.();
|
|
8288
8624
|
};
|
|
8289
8625
|
}
|
|
8290
8626
|
} catch {}
|
|
8291
8627
|
return () => {
|
|
8292
8628
|
disposed = true;
|
|
8293
8629
|
off?.();
|
|
8630
|
+
closeStage?.();
|
|
8294
8631
|
};
|
|
8295
8632
|
}, [boundId, ctx.sessions]);
|
|
8296
8633
|
const [modelFace, setModelFace] = (0, react.useState)(null);
|
|
8297
8634
|
(0, react.useEffect)(() => {
|
|
8298
8635
|
setModelFace(null);
|
|
8299
8636
|
if (boundId === null) return;
|
|
8637
|
+
const closeStage = acquireStageReference(ctx.sessions, boundId);
|
|
8300
8638
|
let disposed = false;
|
|
8301
8639
|
let off;
|
|
8302
8640
|
try {
|
|
@@ -8308,16 +8646,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8308
8646
|
if (!disposed && snap !== void 0 && typeof snap === "object" && snap.next !== void 0 && snap.next !== null) setModelFace(snap.next);
|
|
8309
8647
|
};
|
|
8310
8648
|
read();
|
|
8311
|
-
off = proj.subscribe(
|
|
8649
|
+
off = proj.subscribe(() => {
|
|
8650
|
+
queueMicrotask(read);
|
|
8651
|
+
});
|
|
8312
8652
|
return () => {
|
|
8313
8653
|
disposed = true;
|
|
8314
8654
|
off?.();
|
|
8655
|
+
closeStage?.();
|
|
8315
8656
|
};
|
|
8316
8657
|
}
|
|
8317
8658
|
} catch {}
|
|
8318
8659
|
return () => {
|
|
8319
8660
|
disposed = true;
|
|
8320
8661
|
off?.();
|
|
8662
|
+
closeStage?.();
|
|
8321
8663
|
};
|
|
8322
8664
|
}, [boundId, ctx.sessions]);
|
|
8323
8665
|
(0, react.useEffect)(() => {
|
|
@@ -8346,7 +8688,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8346
8688
|
};
|
|
8347
8689
|
update();
|
|
8348
8690
|
setFeedAttached(true);
|
|
8349
|
-
offSource = source.subscribe(
|
|
8691
|
+
offSource = source.subscribe(() => {
|
|
8692
|
+
queueMicrotask(update);
|
|
8693
|
+
});
|
|
8350
8694
|
return true;
|
|
8351
8695
|
};
|
|
8352
8696
|
const stage = () => {
|
|
@@ -8354,13 +8698,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8354
8698
|
try {
|
|
8355
8699
|
const shell = PANEL_SHELL.current;
|
|
8356
8700
|
if (shell !== null) shell.suppressHandBack(() => {
|
|
8357
|
-
ctx.sessions
|
|
8701
|
+
stageSession(ctx.sessions, boundId);
|
|
8358
8702
|
});
|
|
8359
|
-
else ctx.sessions
|
|
8703
|
+
else stageSession(ctx.sessions, boundId);
|
|
8360
8704
|
} catch {}
|
|
8361
8705
|
};
|
|
8362
8706
|
const reconcile = () => {
|
|
8363
8707
|
if (disposed || offSource !== void 0) return true;
|
|
8708
|
+
if (stagingMode(ctx.sessions) === "retain") {
|
|
8709
|
+
stage();
|
|
8710
|
+
return attach();
|
|
8711
|
+
}
|
|
8364
8712
|
if (ctx.sessions.list?.getSnapshot().current !== boundId) {
|
|
8365
8713
|
stage();
|
|
8366
8714
|
return false;
|
|
@@ -8374,10 +8722,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8374
8722
|
}, 150);
|
|
8375
8723
|
const list = ctx.sessions.list;
|
|
8376
8724
|
if (list !== void 0) offList = list.subscribe(() => {
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8725
|
+
queueMicrotask(() => {
|
|
8726
|
+
if (disposed || reconcile()) {
|
|
8727
|
+
offList?.();
|
|
8728
|
+
offList = void 0;
|
|
8729
|
+
}
|
|
8730
|
+
});
|
|
8381
8731
|
});
|
|
8382
8732
|
return () => {
|
|
8383
8733
|
disposed = true;
|
|
@@ -8442,9 +8792,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8442
8792
|
}
|
|
8443
8793
|
const shell = PANEL_SHELL.current;
|
|
8444
8794
|
if (shell !== null) shell.suppressHandBack(() => {
|
|
8445
|
-
ctx.sessions
|
|
8795
|
+
stageSession(ctx.sessions, sessionId);
|
|
8446
8796
|
});
|
|
8447
|
-
else ctx.sessions
|
|
8797
|
+
else stageSession(ctx.sessions, sessionId);
|
|
8448
8798
|
const actx = ctx.sessions.scope(sessionId);
|
|
8449
8799
|
const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
|
|
8450
8800
|
if (face === void 0) throw new Error("lesson session is not addressable yet");
|
|
@@ -8484,9 +8834,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8484
8834
|
bindLessonSession(lesson.lessonId, sessionId);
|
|
8485
8835
|
const shell = PANEL_SHELL.current;
|
|
8486
8836
|
if (shell !== null) shell.suppressHandBack(() => {
|
|
8487
|
-
ctx.sessions
|
|
8837
|
+
stageSession(ctx.sessions, sessionId);
|
|
8488
8838
|
});
|
|
8489
|
-
else ctx.sessions
|
|
8839
|
+
else stageSession(ctx.sessions, sessionId);
|
|
8490
8840
|
};
|
|
8491
8841
|
const startNewThread = () => {
|
|
8492
8842
|
if (lesson === null) return;
|
|
@@ -8499,8 +8849,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8499
8849
|
const renameLessonThreadUi = (sessionId, title) => {
|
|
8500
8850
|
if (lesson === null) return;
|
|
8501
8851
|
lessonThreadOp(lesson.lessonId, sessionId, "rename", { title }).then(() => {
|
|
8852
|
+
const closeRef = acquireStageReference(ctx.sessions, sessionId);
|
|
8853
|
+
if (closeRef === void 0) return;
|
|
8502
8854
|
const actx = ctx.sessions.scope(sessionId);
|
|
8503
|
-
(actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.rename?.(title).catch(() => {})
|
|
8855
|
+
(actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.rename?.(title).catch(() => {}).finally(() => {
|
|
8856
|
+
closeRef();
|
|
8857
|
+
});
|
|
8504
8858
|
}, (err) => {
|
|
8505
8859
|
setSendError(friendlyError(err, "action"));
|
|
8506
8860
|
});
|
|
@@ -8542,7 +8896,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8542
8896
|
}, (0, react.createElement)(CourseRail, {
|
|
8543
8897
|
data,
|
|
8544
8898
|
activate,
|
|
8545
|
-
setFocus:
|
|
8899
|
+
setFocus: (id) => {
|
|
8900
|
+
setNextHint(null);
|
|
8901
|
+
return guardedSetFocus(id);
|
|
8902
|
+
},
|
|
8546
8903
|
searchLessons,
|
|
8547
8904
|
deleteCourse,
|
|
8548
8905
|
send,
|
|
@@ -8555,7 +8912,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8555
8912
|
stop,
|
|
8556
8913
|
sendImport,
|
|
8557
8914
|
endImportSession,
|
|
8558
|
-
paletteBus
|
|
8915
|
+
paletteBus,
|
|
8916
|
+
nextHintLessonId: nextHint
|
|
8559
8917
|
}), (0, react.createElement)(PaneResizeHandle, {
|
|
8560
8918
|
side: "rail",
|
|
8561
8919
|
clampLive: railClampLive,
|
|
@@ -8629,7 +8987,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8629
8987
|
},
|
|
8630
8988
|
contextMeter,
|
|
8631
8989
|
contextBreakdown: breakdown,
|
|
8632
|
-
uploadAttachment
|
|
8990
|
+
uploadAttachment,
|
|
8991
|
+
boundary,
|
|
8992
|
+
onPointNext: pointAtNext,
|
|
8993
|
+
onDismissBoundary: dismissBoundary
|
|
8633
8994
|
}), (0, react.createElement)(PaneResizeHandle, {
|
|
8634
8995
|
side: "chat",
|
|
8635
8996
|
clampLive: chatClampLive,
|
|
@@ -8966,7 +9327,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8966
9327
|
PANEL_SHELL.current = shell;
|
|
8967
9328
|
}
|
|
8968
9329
|
/** 左栏:course picker, tree, review box, import. */
|
|
8969
|
-
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession }) {
|
|
9330
|
+
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession, nextHintLessonId }) {
|
|
8970
9331
|
const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
|
|
8971
9332
|
const [query, setQuery] = (0, react.useState)("");
|
|
8972
9333
|
const [searchRows, setSearchRows] = (0, react.useState)([]);
|
|
@@ -9194,6 +9555,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
9194
9555
|
return [(0, react.createElement)(ListSectionView, {
|
|
9195
9556
|
key: section.title,
|
|
9196
9557
|
streamingId: streamingLessonId,
|
|
9558
|
+
hintId: nextHintLessonId ?? null,
|
|
9197
9559
|
section: {
|
|
9198
9560
|
title: section.title,
|
|
9199
9561
|
index: section.index,
|
|
@@ -9597,7 +9959,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
9597
9959
|
return cut <= 0 ? path : norm.slice(0, cut);
|
|
9598
9960
|
}
|
|
9599
9961
|
/** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
|
|
9600
|
-
function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment }) {
|
|
9962
|
+
function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment, boundary, onPointNext, onDismissBoundary }) {
|
|
9601
9963
|
const stuck = (0, react.useRef)(true);
|
|
9602
9964
|
const [showFab, setShowFab] = (0, react.useState)(false);
|
|
9603
9965
|
const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
|
|
@@ -10104,7 +10466,23 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
10104
10466
|
onClick: () => {
|
|
10105
10467
|
if (!dormant) send(s.message);
|
|
10106
10468
|
}
|
|
10107
|
-
}, s.label))) : null,
|
|
10469
|
+
}, s.label))) : null, boundary !== null ? (0, react.createElement)("div", {
|
|
10470
|
+
className: "lks14-boundary",
|
|
10471
|
+
"data-testid": "boundary-card"
|
|
10472
|
+
}, (0, react.createElement)(IconCrownFill16, { size: 16 }), (0, react.createElement)("div", { className: "lks14-boundary-text" }, (0, react.createElement)("div", { className: "lks14-boundary-title" }, boundary.last ? tr("boundary.course.done") : tr("boundary.done")), (0, react.createElement)("div", { className: "lks14-boundary-sub" }, boundary.last ? tr("boundary.last.sub") : boundary.nextTitle !== null ? courseScope === "course" ? tr("boundary.thread.sub", { title: boundary.nextTitle }) : tr("boundary.next.sub", { title: boundary.nextTitle }) : "")), !boundary.last && boundary.nextId !== null ? (0, react.createElement)("button", {
|
|
10473
|
+
className: "lks-btn primary",
|
|
10474
|
+
"data-testid": "boundary-next",
|
|
10475
|
+
onClick: () => {
|
|
10476
|
+
if (courseScope === "course" && !dormant) {
|
|
10477
|
+
send(tr("boundary.thread.send", { title: boundary.nextTitle ?? "" }));
|
|
10478
|
+
onDismissBoundary();
|
|
10479
|
+
} else onPointNext(boundary.nextId);
|
|
10480
|
+
}
|
|
10481
|
+
}, courseScope === "course" ? tr("boundary.thread.point") : tr("boundary.point")) : null, (0, react.createElement)("button", {
|
|
10482
|
+
className: "lks14-boundary-x",
|
|
10483
|
+
"aria-label": tr("boundary.dismiss"),
|
|
10484
|
+
onClick: onDismissBoundary
|
|
10485
|
+
}, "✕")) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
|
|
10108
10486
|
className: "lks14-composer-card",
|
|
10109
10487
|
onDragOver: (e) => {
|
|
10110
10488
|
if (e.dataTransfer?.types.includes("Files") === true) e.preventDefault();
|
|
@@ -10829,8 +11207,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
10829
11207
|
* event.
|
|
10830
11208
|
*
|
|
10831
11209
|
* Differences from stardeck's original: our own attribute namespace, a
|
|
10832
|
-
* hand-back SUPPRESSION hook (the panel itself
|
|
10833
|
-
*
|
|
11210
|
+
* hand-back SUPPRESSION hook (the panel itself stages the focus lesson's
|
|
11211
|
+
* thread through stage-session.ts — an internal navigation must not close the
|
|
10834
11212
|
* panel; only USER session navigation hands the column back), and dsw-token
|
|
10835
11213
|
* styling via the injected stylesheet.
|
|
10836
11214
|
* @module dsh-plugin-lookatstudy/client/shell-entry
|
|
@@ -10989,13 +11367,21 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
10989
11367
|
doc.addEventListener(ACTIVATE_EVENT, onOtherActivate);
|
|
10990
11368
|
let unsubscribeSessions;
|
|
10991
11369
|
if (sessions?.list !== void 0) {
|
|
10992
|
-
|
|
11370
|
+
const readSelection = () => {
|
|
11371
|
+
const snap = sessions.list.getSnapshot();
|
|
11372
|
+
if (snap.current !== void 0) return snap.current;
|
|
11373
|
+
return mainSelectionOf(snap.byId);
|
|
11374
|
+
};
|
|
11375
|
+
let lastCurrent = readSelection();
|
|
10993
11376
|
unsubscribeSessions = sessions.list.subscribe(() => {
|
|
10994
|
-
const
|
|
10995
|
-
|
|
10996
|
-
|
|
10997
|
-
|
|
10998
|
-
|
|
11377
|
+
const wasSuppressed = suppressed;
|
|
11378
|
+
queueMicrotask(() => {
|
|
11379
|
+
const next = readSelection();
|
|
11380
|
+
if (next === lastCurrent) return;
|
|
11381
|
+
const wasHydration = lastCurrent === void 0;
|
|
11382
|
+
lastCurrent = next;
|
|
11383
|
+
if (shouldHandBack(wasSuppressed, wasHydration)) state.setOpen(false);
|
|
11384
|
+
});
|
|
10999
11385
|
});
|
|
11000
11386
|
}
|
|
11001
11387
|
const unsubscribe = state.subscribe(applyActive);
|
|
@@ -11020,6 +11406,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11020
11406
|
doc.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
|
|
11021
11407
|
unsubscribeSessions?.();
|
|
11022
11408
|
unsubscribe();
|
|
11409
|
+
unstageSession();
|
|
11023
11410
|
root?.unmount();
|
|
11024
11411
|
container?.remove();
|
|
11025
11412
|
row.remove();
|
|
@@ -11031,6 +11418,79 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11031
11418
|
return !suppressed && !wasHydration;
|
|
11032
11419
|
}
|
|
11033
11420
|
//#endregion
|
|
11421
|
+
//#region src/learner-profile.ts
|
|
11422
|
+
/**
|
|
11423
|
+
* 学习者画像(声明侧)— ported from LookatStudy shared/learner-profile.ts
|
|
11424
|
+
* (upstream v0.36.0, MIT License). The learner-declared profile: MBTI as a
|
|
11425
|
+
* shortcut entry expanding into the four style dims (the dims are the source
|
|
11426
|
+
* of truth), the OIT motivation stage, interests, and a free note. The
|
|
11427
|
+
* profile adjusts HOW the tutor teaches — it never touches BKT/mastery/
|
|
11428
|
+
* unlock numbers (upstream SPEC §7 forbidden zone). Injection copy carries
|
|
11429
|
+
* the anti-injection annotation ("background data, not instructions").
|
|
11430
|
+
* @module dsh-plugin-lookatstudy/learner-profile
|
|
11431
|
+
*/
|
|
11432
|
+
const MBTI_TYPES = [
|
|
11433
|
+
"INTJ",
|
|
11434
|
+
"INTP",
|
|
11435
|
+
"ENTJ",
|
|
11436
|
+
"ENTP",
|
|
11437
|
+
"INFJ",
|
|
11438
|
+
"INFP",
|
|
11439
|
+
"ENFJ",
|
|
11440
|
+
"ENFP",
|
|
11441
|
+
"ISTJ",
|
|
11442
|
+
"ISFJ",
|
|
11443
|
+
"ESTJ",
|
|
11444
|
+
"ESFJ",
|
|
11445
|
+
"ISTP",
|
|
11446
|
+
"ISFP",
|
|
11447
|
+
"ESTP",
|
|
11448
|
+
"ESFP"
|
|
11449
|
+
];
|
|
11450
|
+
/**
|
|
11451
|
+
* 动机阶段(OIT 内化连续体 2-6 级;无动机级不设)。用户侧只见白话名,
|
|
11452
|
+
* 临床语义只活在注入文案里。AI 结构上不可提议(patch 类型排除)。
|
|
11453
|
+
*/
|
|
11454
|
+
const MOTIVE_STAGES = [
|
|
11455
|
+
"external",
|
|
11456
|
+
"introjected",
|
|
11457
|
+
"identified",
|
|
11458
|
+
"integrated",
|
|
11459
|
+
"intrinsic"
|
|
11460
|
+
];
|
|
11461
|
+
/** MBTI 四字母 → style 四维(快捷入口展开为真源初值,用户可逐维手调)。 */
|
|
11462
|
+
function expandMbtiToStyle(mbti) {
|
|
11463
|
+
return {
|
|
11464
|
+
start: mbti.includes("N") ? "framework" : "analogy",
|
|
11465
|
+
interaction: mbti.includes("E") ? "dialogue" : "lecture",
|
|
11466
|
+
feedback: mbti.includes("T") ? "direct" : "encouraging",
|
|
11467
|
+
pacing: mbti.includes("P") ? "exploratory" : "sequential"
|
|
11468
|
+
};
|
|
11469
|
+
}
|
|
11470
|
+
/** 动机阶段白话名 + tagline(用户侧只见白话;临床语义只在注入块)。 */
|
|
11471
|
+
const MOTIVE_DISPLAY = {
|
|
11472
|
+
external: {
|
|
11473
|
+
name: "为了考试/面试",
|
|
11474
|
+
tagline: "这场考试/面试在前头等着,得过去。"
|
|
11475
|
+
},
|
|
11476
|
+
introjected: {
|
|
11477
|
+
name: "为了填满休息时间",
|
|
11478
|
+
tagline: "闲着也是闲着,学点总没坏处。"
|
|
11479
|
+
},
|
|
11480
|
+
identified: {
|
|
11481
|
+
name: "未来能用得上",
|
|
11482
|
+
tagline: "这本事存着,总有一天用得上。"
|
|
11483
|
+
},
|
|
11484
|
+
integrated: {
|
|
11485
|
+
name: "终身学习",
|
|
11486
|
+
tagline: "学习这事儿,我打算干一辈子。"
|
|
11487
|
+
},
|
|
11488
|
+
intrinsic: {
|
|
11489
|
+
name: "享受学习过程",
|
|
11490
|
+
tagline: "懂的那个瞬间,本身就挺爽。"
|
|
11491
|
+
}
|
|
11492
|
+
};
|
|
11493
|
+
//#endregion
|
|
11034
11494
|
//#region src/client/settings.tsx
|
|
11035
11495
|
/**
|
|
11036
11496
|
* The plugin's settings page: one `settings.section` entry riding the host's
|
|
@@ -11059,12 +11519,162 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11059
11519
|
hintKey: "soul.practice.hint"
|
|
11060
11520
|
}
|
|
11061
11521
|
];
|
|
11522
|
+
/** The four style dims (label key per value), upstream's DIM_LABELS UI face. */
|
|
11523
|
+
const STYLE_DIMS = [
|
|
11524
|
+
{
|
|
11525
|
+
key: "start",
|
|
11526
|
+
values: [["analogy", "settings.style.analogy"], ["framework", "settings.style.framework"]]
|
|
11527
|
+
},
|
|
11528
|
+
{
|
|
11529
|
+
key: "interaction",
|
|
11530
|
+
values: [["dialogue", "settings.style.dialogue"], ["lecture", "settings.style.lecture"]]
|
|
11531
|
+
},
|
|
11532
|
+
{
|
|
11533
|
+
key: "feedback",
|
|
11534
|
+
values: [["direct", "settings.style.direct"], ["encouraging", "settings.style.encouraging"]]
|
|
11535
|
+
},
|
|
11536
|
+
{
|
|
11537
|
+
key: "pacing",
|
|
11538
|
+
values: [["sequential", "settings.style.sequential"], ["exploratory", "settings.style.exploratory"]]
|
|
11539
|
+
}
|
|
11540
|
+
];
|
|
11541
|
+
/**
|
|
11542
|
+
* The learner-profile editor + AI-suggestion consumer (upstream v0.36).
|
|
11543
|
+
* Form state seeds from the persisted profile; saving writes the human path
|
|
11544
|
+
* (which stales older pending suggestions via the updatedAt arbitration).
|
|
11545
|
+
*/
|
|
11546
|
+
function ProfileSection({ data, save, resolve, fire }) {
|
|
11547
|
+
const profile = data?.profile ?? null;
|
|
11548
|
+
const [name, setName] = (0, react.useState)(profile?.name ?? "");
|
|
11549
|
+
const [mbti, setMbti] = (0, react.useState)(profile?.mbti ?? "");
|
|
11550
|
+
const [style, setStyle] = (0, react.useState)(() => ({
|
|
11551
|
+
start: profile?.style.start ?? null,
|
|
11552
|
+
interaction: profile?.style.interaction ?? null,
|
|
11553
|
+
feedback: profile?.style.feedback ?? null,
|
|
11554
|
+
pacing: profile?.style.pacing ?? null
|
|
11555
|
+
}));
|
|
11556
|
+
const [interests, setInterests] = (0, react.useState)(profile?.interests?.join("、") ?? "");
|
|
11557
|
+
const [motive, setMotive] = (0, react.useState)(profile?.motiveStage ?? "");
|
|
11558
|
+
const [freeNote, setFreeNote] = (0, react.useState)(profile?.freeNote ?? "");
|
|
11559
|
+
const seededFor = (0, react.useRef)(null);
|
|
11560
|
+
(0, react.useEffect)(() => {
|
|
11561
|
+
const stamp = profile?.updatedAt ?? null;
|
|
11562
|
+
if (stamp === null || stamp === seededFor.current) return;
|
|
11563
|
+
seededFor.current = stamp;
|
|
11564
|
+
setName(profile?.name ?? "");
|
|
11565
|
+
setMbti(profile?.mbti ?? "");
|
|
11566
|
+
setStyle({
|
|
11567
|
+
start: profile?.style.start ?? null,
|
|
11568
|
+
interaction: profile?.style.interaction ?? null,
|
|
11569
|
+
feedback: profile?.style.feedback ?? null,
|
|
11570
|
+
pacing: profile?.style.pacing ?? null
|
|
11571
|
+
});
|
|
11572
|
+
setInterests(profile?.interests?.join("、") ?? "");
|
|
11573
|
+
setMotive(profile?.motiveStage ?? "");
|
|
11574
|
+
setFreeNote(profile?.freeNote ?? "");
|
|
11575
|
+
}, [profile?.updatedAt]);
|
|
11576
|
+
const saveAll = () => {
|
|
11577
|
+
fire(save({
|
|
11578
|
+
name: name.trim() === "" ? null : name.trim(),
|
|
11579
|
+
mbti: mbti === "" ? null : mbti,
|
|
11580
|
+
interests,
|
|
11581
|
+
freeNote: freeNote.trim() === "" ? null : freeNote.trim(),
|
|
11582
|
+
motiveStage: motive === "" ? null : motive,
|
|
11583
|
+
style
|
|
11584
|
+
}));
|
|
11585
|
+
};
|
|
11586
|
+
const pickMbti = (next) => {
|
|
11587
|
+
setMbti(next);
|
|
11588
|
+
if (next !== "" && MBTI_TYPES.includes(next)) {
|
|
11589
|
+
const expanded = expandMbtiToStyle(next);
|
|
11590
|
+
setStyle({ ...expanded });
|
|
11591
|
+
}
|
|
11592
|
+
};
|
|
11593
|
+
const suggestions = data?.pendingProfileProposals ?? [];
|
|
11594
|
+
return (0, react.createElement)("section", {
|
|
11595
|
+
className: "lks-set-row",
|
|
11596
|
+
"data-testid": "profile-section"
|
|
11597
|
+
}, (0, react.createElement)("h3", null, tr("settings.profile")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.profile.hint")), (0, react.createElement)("div", { className: "lks-profile-grid" }, (0, react.createElement)("label", null, tr("settings.profile.name"), (0, react.createElement)("input", {
|
|
11598
|
+
className: "lks-set-input",
|
|
11599
|
+
value: name,
|
|
11600
|
+
"data-testid": "profile-name",
|
|
11601
|
+
onChange: (e) => {
|
|
11602
|
+
setName(e.target.value);
|
|
11603
|
+
}
|
|
11604
|
+
})), (0, react.createElement)("label", null, tr("settings.profile.mbti"), (0, react.createElement)("select", {
|
|
11605
|
+
className: "lks-set-select",
|
|
11606
|
+
value: mbti,
|
|
11607
|
+
"data-testid": "profile-mbti",
|
|
11608
|
+
onChange: (e) => {
|
|
11609
|
+
pickMbti(e.target.value);
|
|
11610
|
+
}
|
|
11611
|
+
}, (0, react.createElement)("option", { value: "" }, tr("settings.profile.unset")), ...MBTI_TYPES.map((t) => (0, react.createElement)("option", {
|
|
11612
|
+
key: t,
|
|
11613
|
+
value: t
|
|
11614
|
+
}, t)))), (0, react.createElement)("label", null, tr("settings.profile.interests"), (0, react.createElement)("input", {
|
|
11615
|
+
className: "lks-set-input",
|
|
11616
|
+
value: interests,
|
|
11617
|
+
"data-testid": "profile-interests",
|
|
11618
|
+
placeholder: tr("settings.profile.interests.ph"),
|
|
11619
|
+
onChange: (e) => {
|
|
11620
|
+
setInterests(e.target.value);
|
|
11621
|
+
}
|
|
11622
|
+
})), (0, react.createElement)("label", null, tr("settings.profile.note"), (0, react.createElement)("input", {
|
|
11623
|
+
className: "lks-set-input",
|
|
11624
|
+
value: freeNote,
|
|
11625
|
+
onChange: (e) => {
|
|
11626
|
+
setFreeNote(e.target.value);
|
|
11627
|
+
}
|
|
11628
|
+
})), ...STYLE_DIMS.map((dim) => (0, react.createElement)("label", { key: dim.key }, tr(`settings.style.${dim.key}`), (0, react.createElement)("span", { className: "lks-pills" }, ...dim.values.map(([value, labelKey]) => (0, react.createElement)("button", {
|
|
11629
|
+
key: value,
|
|
11630
|
+
className: `lks-pill${style[dim.key] === value ? " on" : ""}`,
|
|
11631
|
+
onClick: () => {
|
|
11632
|
+
setStyle((cur) => ({
|
|
11633
|
+
...cur,
|
|
11634
|
+
[dim.key]: cur[dim.key] === value ? null : value
|
|
11635
|
+
}));
|
|
11636
|
+
}
|
|
11637
|
+
}, tr(labelKey)))))), (0, react.createElement)("label", null, tr("settings.profile.motive"), (0, react.createElement)("span", { className: "lks-pills" }, (0, react.createElement)("button", {
|
|
11638
|
+
className: `lks-pill${motive === "" ? " on" : ""}`,
|
|
11639
|
+
onClick: () => {
|
|
11640
|
+
setMotive("");
|
|
11641
|
+
}
|
|
11642
|
+
}, tr("settings.profile.unset")), ...MOTIVE_STAGES.map((stage) => (0, react.createElement)("button", {
|
|
11643
|
+
key: stage,
|
|
11644
|
+
className: `lks-pill${motive === stage ? " on" : ""}`,
|
|
11645
|
+
title: MOTIVE_DISPLAY[stage].tagline,
|
|
11646
|
+
onClick: () => {
|
|
11647
|
+
setMotive(stage);
|
|
11648
|
+
}
|
|
11649
|
+
}, MOTIVE_DISPLAY[stage].name))))), (0, react.createElement)("div", null, (0, react.createElement)("button", {
|
|
11650
|
+
className: "lks-btn primary",
|
|
11651
|
+
"data-testid": "profile-save",
|
|
11652
|
+
onClick: saveAll
|
|
11653
|
+
}, tr("settings.profile.save")), (0, react.createElement)("span", { className: "lks-set-state" }, tr("settings.profile.private"))), suggestions.length > 0 ? (0, react.createElement)("div", {
|
|
11654
|
+
className: "lks-profile-suggests",
|
|
11655
|
+
"data-testid": "profile-suggest-card"
|
|
11656
|
+
}, (0, react.createElement)("h4", null, tr("settings.suggest.head")), ...suggestions.map((sg) => (0, react.createElement)("div", {
|
|
11657
|
+
key: sg.id,
|
|
11658
|
+
className: "lks-profile-suggest"
|
|
11659
|
+
}, (0, react.createElement)("p", null, sg.rationale), (0, react.createElement)("div", { className: "lks-pills" }, (0, react.createElement)("button", {
|
|
11660
|
+
className: "lks-btn primary",
|
|
11661
|
+
"data-testid": "profile-suggest-apply",
|
|
11662
|
+
onClick: () => {
|
|
11663
|
+
fire(resolve(sg.id, true));
|
|
11664
|
+
}
|
|
11665
|
+
}, tr("settings.suggest.apply")), (0, react.createElement)("button", {
|
|
11666
|
+
className: "lks-btn ghost",
|
|
11667
|
+
onClick: () => {
|
|
11668
|
+
fire(resolve(sg.id, false));
|
|
11669
|
+
}
|
|
11670
|
+
}, tr("settings.suggest.ignore")))))) : null);
|
|
11671
|
+
}
|
|
11062
11672
|
/**
|
|
11063
11673
|
* The settings section component. Props from the settings shell (`close`) are
|
|
11064
11674
|
* unused — every control stays on the page.
|
|
11065
11675
|
*/
|
|
11066
11676
|
function StudySettingsSection() {
|
|
11067
|
-
const { data, setMode, activate, setHistoryBudget } = useStudy();
|
|
11677
|
+
const { data, setMode, activate, setHistoryBudget, saveProfile, resolveProfileProposal } = useStudy();
|
|
11068
11678
|
const [error, setError] = (0, react.useState)(null);
|
|
11069
11679
|
const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
|
|
11070
11680
|
const fire = (action) => {
|
|
@@ -11114,7 +11724,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11114
11724
|
days: progress.streak,
|
|
11115
11725
|
best: progress.longestStreak,
|
|
11116
11726
|
freeze: progress.freezeCount
|
|
11117
|
-
}))
|
|
11727
|
+
})), (0, react.createElement)("li", { "data-testid": "recap-mastered" }, tr("settings.stats.mastered", {
|
|
11728
|
+
count: (data?.courses ?? []).reduce((n, c) => n + c.mastered, 0),
|
|
11729
|
+
due: data?.dueCount ?? 0
|
|
11730
|
+
})))), (0, react.createElement)(ProfileSection, {
|
|
11731
|
+
data,
|
|
11732
|
+
save: saveProfile,
|
|
11733
|
+
resolve: resolveProfileProposal,
|
|
11734
|
+
fire
|
|
11735
|
+
}), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.about")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.about.hint")), data?.version ? (0, react.createElement)("a", {
|
|
11118
11736
|
href: `https://github.com/Kaiji-Z/dsh-plugin-lookatstudy/releases/tag/v${data.version}`,
|
|
11119
11737
|
target: "_blank",
|
|
11120
11738
|
rel: "noreferrer"
|