dsh-plugin-lookatstudy 0.12.4 → 0.13.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/lib/client.js CHANGED
@@ -235,6 +235,9 @@ window.__ModuleLoader__.load({
235
235
  .lks-zone-h{font-size:14px;color:var(--dsw-alias-label-secondary);margin:0 0 8px;font-weight:600}
236
236
  .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}
237
237
  .lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
238
+ .lks-note-del{float:right;clear:right;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px;border-radius:5px;line-height:0}
239
+ .lks-note-del:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}
240
+ .lks-note-del.armed{color:#fff;background:var(--dsw-alias-state-error-primary)}
238
241
  .lks-note .lks-note-title{font-weight:600;font-size:13px}
239
242
  .lks-note .lks-note-text{margin-top:4px;font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.65}
240
243
  .lks-note .lks-note-text p{margin:4px 0}
@@ -246,6 +249,12 @@ window.__ModuleLoader__.load({
246
249
  /* inline error text (write-action failures) */
247
250
  .lks-propcard-err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:6px;flex:none}
248
251
 
252
+ /* read-aloud bar (teach pane, under the view tabs) */
253
+ .lks-readbar{display:flex;align-items:center;gap:6px;margin:0 0 8px;min-height:26px}
254
+ .lks-readbar .lks-btn{display:inline-flex;align-items:center;gap:4px}
255
+ .lks-readbar-cur{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-info-primary,var(--dsw-alias-business-primary));padding-left:8px}
256
+ .lks-readbar-notice{flex:none;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
257
+
249
258
  /* settings section (settings.section entry inside the host settings shell) */
250
259
  .lks-settings{display:flex;flex-direction:column;gap:18px;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}
251
260
  .lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
@@ -715,6 +724,35 @@ window.__ModuleLoader__.load({
715
724
  });
716
725
  this.refresh();
717
726
  }
727
+ /** Delete one notebook entry from a lesson's Cornell zones. */
728
+ async deleteNote(lessonId, noteId) {
729
+ await fetchJson("/lookatstudy/api/note/delete", {
730
+ method: "POST",
731
+ headers: { "content-type": "application/json" },
732
+ body: JSON.stringify({
733
+ lessonId,
734
+ noteId
735
+ })
736
+ });
737
+ this.refresh();
738
+ }
739
+ /** Synthesize one speakable chunk to MP3 (host-side Edge TTS, cache-first). */
740
+ async tts(text, voice) {
741
+ const body = await fetchJson("/lookatstudy/api/tts", {
742
+ method: "POST",
743
+ headers: { "content-type": "application/json" },
744
+ body: JSON.stringify({
745
+ text,
746
+ voice
747
+ })
748
+ });
749
+ const raw = body.dataBase64;
750
+ if (body.ok !== true || typeof raw !== "string") throw new Error("tts response missing audio payload");
751
+ const bin = atob(raw);
752
+ const bytes = new Uint8Array(bin.length);
753
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
754
+ return bytes.buffer;
755
+ }
718
756
  };
719
757
  /** The one shared store instance backing every study seat component. */
720
758
  const studyStore = new StudyStore();
@@ -727,6 +765,7 @@ window.__ModuleLoader__.load({
727
765
  setFocus: studyStore.setFocus.bind(studyStore),
728
766
  searchLessons: studyStore.searchLessons.bind(studyStore),
729
767
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
768
+ deleteNote: studyStore.deleteNote.bind(studyStore),
730
769
  bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
731
770
  };
732
771
  }
@@ -1567,6 +1606,14 @@ window.__ModuleLoader__.load({
1567
1606
  "rail.delete": "删除本课程",
1568
1607
  "rail.delete.confirm": "确认删除?",
1569
1608
  "rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
1609
+ "note.delete": "删除本条笔记",
1610
+ "note.delete.confirm": "确认删除?",
1611
+ "read.play": "朗读本课",
1612
+ "read.stop": "停止朗读",
1613
+ "read.pause": "暂停朗读",
1614
+ "read.resume": "继续朗读",
1615
+ "read.engine.system": "网络合成不可用,已切换为系统语音",
1616
+ "read.unavailable": "朗读不可用",
1570
1617
  "rail.section.collapse": "折叠本章节",
1571
1618
  "rail.section.expand": "展开本章节({count} 课时)",
1572
1619
  "rail.section.count": "{count} 课",
@@ -1687,6 +1734,14 @@ window.__ModuleLoader__.load({
1687
1734
  "rail.delete": "Delete this course",
1688
1735
  "rail.delete.confirm": "Delete?",
1689
1736
  "rail.delete.title.confirm": "Click again to confirm (all progress and notes included). Back up via the state file in Settings first",
1737
+ "note.delete": "Delete this note",
1738
+ "note.delete.confirm": "Delete?",
1739
+ "read.play": "Read aloud",
1740
+ "read.stop": "Stop reading",
1741
+ "read.pause": "Pause reading",
1742
+ "read.resume": "Resume reading",
1743
+ "read.engine.system": "Network synthesis unavailable — switched to the system voice",
1744
+ "read.unavailable": "Read-aloud unavailable",
1690
1745
  "rail.section.collapse": "Collapse this section",
1691
1746
  "rail.section.expand": "Expand this section ({count} lessons)",
1692
1747
  "rail.section.count": "{count} lessons",
@@ -1905,6 +1960,433 @@ window.__ModuleLoader__.load({
1905
1960
  container.append(legend);
1906
1961
  }
1907
1962
  //#endregion
1963
+ //#region src/vendor/speech-text.ts
1964
+ /**
1965
+ * Vendored from LookatStudy shared/speech-text.ts (MIT License,
1966
+ * https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0 — includes the
1967
+ * v11.5 whole-display-group avoidance fix era. Unmodified except this
1968
+ * provenance header.
1969
+ *
1970
+ * speech-text —— TTS 朗读文本处理(纯函数,渲染层/主进程共用)。
1971
+ *
1972
+ * normalizeSpeechText: markdown → 可朗读纯文本。代码不读(围栏/行内整体移除),
1973
+ * 链接留文字,强调/标题/列表/引用/表格标记剥离 —— 导师"念的是话,不是版面"。
1974
+ *
1975
+ * splitSentences: 流式友好的切句。每次喂"累积文本"返回完整句 + 余量(幂等,StrictMode 安全);
1976
+ * flush=true 时尾句强制吐出。终止标点:中英句号/叹/问/分号 + 省略号;ASCII '.' 要求后跟
1977
+ * 空白且前字符非数字(3.14 不切)。超过 maxBuffer 仍无终止标点 → 在最后的软标点(、,;: 空白)
1978
+ * 处断开,再无则硬断 —— 保证"导师边生成边念"的流式管线永远不会饿死。
1979
+ *
1980
+ * v11.2 表意分隔:换行与 emoji 也是句界。无标点的段落/列表/短句流文本,
1981
+ * 旧逻辑的强制断句块会在显示层并组吞整段(高亮整段到结尾);现在逐行/逐 emoji
1982
+ * 成句,显示组另加长度上限兜底 —— karaoke 高亮粒度永远可控。
1983
+ */
1984
+ /** 终止标点(出现即成句,连续终止/右引号并吞进句尾) */
1985
+ const HARD = /* @__PURE__ */ new Set([
1986
+ "。",
1987
+ "!",
1988
+ "?",
1989
+ "!",
1990
+ "?",
1991
+ ";",
1992
+ ";",
1993
+ "…"
1994
+ ]);
1995
+ /** 软标点(超长兜底断句位) */
1996
+ const SOFT = /* @__PURE__ */ new Set([
1997
+ ",",
1998
+ ",",
1999
+ "、",
2000
+ ":",
2001
+ ":",
2002
+ " ",
2003
+ " "
2004
+ ]);
2005
+ /** v11.2 emoji 基字符范围(表意/象形/杂项符号/箭头符号区);修饰符(VS16/ZWJ/肤色)不单独成界 */
2006
+ const EMOJI_CP_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u;
2007
+ function isEmojiCp(cp) {
2008
+ return cp != null && EMOJI_CP_RE.test(String.fromCodePoint(cp));
2009
+ }
2010
+ /** 句尾可并吞的右闭合符 */
2011
+ const CLOSERS = /* @__PURE__ */ new Set([
2012
+ "”",
2013
+ "\"",
2014
+ "」",
2015
+ "』",
2016
+ "》",
2017
+ ")",
2018
+ "】",
2019
+ "])".slice(0, 1)
2020
+ ]);
2021
+ function normalizeSpeechText(md) {
2022
+ let s = md;
2023
+ s = s.replace(/\r\n?/g, "\n");
2024
+ s = s.replace(/(?:^|\n)[ \t]*(?:```|~~~)[^\n]*\n[\s\S]*?(?:\n[ \t]*(?:```|~~~)[^\n]*|\n?$)/g, "\n");
2025
+ s = s.replace(/`[^`\n]*`/g, "");
2026
+ s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
2027
+ s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
2028
+ s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
2029
+ s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
2030
+ s = s.replace(/^[ \t]*>[ \t]?/gm, "");
2031
+ s = s.replace(/\|/g, " ");
2032
+ s = s.replace(/^[ \t]*[-: ]{3,}[ \t]*$/gm, "");
2033
+ s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
2034
+ s = s.replace(/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/g, "$2");
2035
+ s = s.replace(/~~(.+?)~~/g, "$1");
2036
+ s = s.replace(/\n{3,}/g, "\n\n");
2037
+ return s.trim();
2038
+ }
2039
+ /**
2040
+ * v11.2 朗读句表单一入口(v11.4 起仅合成侧使用):tts-service 用它切段;
2041
+ * 渲染层 karaoke **不再调用**(改吃 ttsAudio.sentence 权威原文,见
2042
+ * playedSentencePrefix)——句表从合成侧单向流出,显示侧零复算零分叉。
2043
+ */
2044
+ function speechSentencesOf(text) {
2045
+ return splitSentences(normalizeSpeechText(text), { flush: true }).sentences;
2046
+ }
2047
+ function splitSentences(text, opts = {}) {
2048
+ const maxBuffer = Math.max(8, opts.maxBuffer ?? 120);
2049
+ const flush = opts.flush ?? false;
2050
+ const out = [];
2051
+ const n = text.length;
2052
+ let start = 0;
2053
+ let i = 0;
2054
+ let lastSoft = -1;
2055
+ const emit = (end) => {
2056
+ const piece = text.slice(start, end).trim();
2057
+ if (piece) out.push(piece);
2058
+ start = end;
2059
+ lastSoft = -1;
2060
+ };
2061
+ while (i < n) {
2062
+ const ch = text[i];
2063
+ const cp = text.codePointAt(i);
2064
+ if (isEmojiCp(cp)) {
2065
+ let j = i + (cp > 65535 ? 2 : 1);
2066
+ while (j < n) {
2067
+ const cj = text.codePointAt(j);
2068
+ const ul = cj > 65535 ? 2 : 1;
2069
+ if (cj === 65039 || cj === 8205 || cj >= 127995 && cj <= 127999 || isEmojiCp(cj)) {
2070
+ j += ul;
2071
+ continue;
2072
+ }
2073
+ break;
2074
+ }
2075
+ emit(j);
2076
+ i = j;
2077
+ continue;
2078
+ }
2079
+ if (ch === "\n") {
2080
+ const piece = text.slice(start, i + 1).replace(/[^\S\n]+$/, "");
2081
+ if (piece.trim()) out.push(piece);
2082
+ start = i + 1;
2083
+ lastSoft = -1;
2084
+ i++;
2085
+ continue;
2086
+ }
2087
+ if (HARD.has(ch)) {
2088
+ let j = i + 1;
2089
+ while (j < n && (HARD.has(text[j]) || CLOSERS.has(text[j]))) j++;
2090
+ emit(j);
2091
+ i = j;
2092
+ continue;
2093
+ }
2094
+ if (ch === ".") {
2095
+ const prev = i > 0 ? text[i - 1] : "";
2096
+ const next = i + 1 < n ? text[i + 1] : "";
2097
+ if ((next === "" || /\s/.test(next)) && !/\d/.test(prev)) {
2098
+ let j = i + 1;
2099
+ while (j < n && (CLOSERS.has(text[j]) || HARD.has(text[j]))) j++;
2100
+ emit(j);
2101
+ i = j;
2102
+ continue;
2103
+ }
2104
+ }
2105
+ if (SOFT.has(ch)) lastSoft = i;
2106
+ if (i - start + 1 > maxBuffer) {
2107
+ if (lastSoft > start) {
2108
+ const cut = lastSoft;
2109
+ emit(cut);
2110
+ start = cut + 1;
2111
+ } else {
2112
+ emit(i + 1);
2113
+ i++;
2114
+ continue;
2115
+ }
2116
+ }
2117
+ i++;
2118
+ }
2119
+ const rest = text.slice(start).trim();
2120
+ if (flush && rest) out.push(rest);
2121
+ return {
2122
+ sentences: out,
2123
+ rest: flush ? "" : rest
2124
+ };
2125
+ }
2126
+ //#endregion
2127
+ //#region src/vendor/math-speech.ts
2128
+ /**
2129
+ * Vendored from LookatStudy shared/math-speech.ts (MIT License,
2130
+ * https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0. Unmodified
2131
+ * except this provenance header. zh-only by design upstream — the plugin's
2132
+ * read-aloud pipeline applies it for zh and strips $..$ delimiters for other
2133
+ * locales (conversion for more languages is future work).
2134
+ *
2135
+ * math-speech —— LaTeX 公式 → 中文口语(v0.19 朗读口语化,纯函数,verify 直测)。
2136
+ *
2137
+ * 只在**合成侧**应用(真正念出来的文本);karaoke 高亮/匹配层继续吃 `$..$` 原文
2138
+ * (DOM 侧 getTextModel 收 KaTeX annotation 的 TeX 源,两侧在 canonical 空间对齐)
2139
+ * ——念的是人话,亮的是原文,互不干扰。
2140
+ *
2141
+ * 规则表起步:常见记号(分式/根号/上下标/希腊字母/关系与算符)覆盖优先,
2142
+ * 未覆盖宏退化为逐字母(好于念"反斜杠 f-r-a-c")。
2143
+ */
2144
+ /** 单符号命令 → 中文(长命令先试,避免 \le 被 \leq 截断)。 */
2145
+ const SYMBOLS = [
2146
+ ["\\leq", "小于等于"],
2147
+ ["\\le", "小于等于"],
2148
+ ["\\geq", "大于等于"],
2149
+ ["\\ge", "大于等于"],
2150
+ ["\\neq", "不等于"],
2151
+ ["\\ne", "不等于"],
2152
+ ["\\approx", "约等于"],
2153
+ ["\\equiv", "恒等于"],
2154
+ ["\\pm", "正负"],
2155
+ ["\\times", "乘以"],
2156
+ ["\\cdot", "点乘"],
2157
+ ["\\div", "除以"],
2158
+ ["\\to", "趋于"],
2159
+ ["\\rightarrow", "趋于"],
2160
+ ["\\Rightarrow", "推出"],
2161
+ ["\\infty", "无穷"],
2162
+ ["\\sum", "求和"],
2163
+ ["\\prod", "连乘"],
2164
+ ["\\int", "积分"],
2165
+ ["\\lim", "极限"],
2166
+ ["\\log", "对数"],
2167
+ ["\\ln", "自然对数"],
2168
+ ["\\exp", "指数"],
2169
+ ["\\in", "属于"],
2170
+ ["\\subset", "包含于"],
2171
+ ["\\cup", "并"],
2172
+ ["\\cap", "交"],
2173
+ ["\\forall", "任意"],
2174
+ ["\\exists", "存在"],
2175
+ ["\\partial", "偏导"],
2176
+ ["\\nabla", "梯度"],
2177
+ ["\\angle", "角"],
2178
+ ["\\degree", "度"],
2179
+ ["\\cdots", "点点点"],
2180
+ ["\\ldots", "点点点"]
2181
+ ];
2182
+ const GREEK = {
2183
+ alpha: "阿尔法",
2184
+ beta: "贝塔",
2185
+ gamma: "伽马",
2186
+ delta: "德尔塔",
2187
+ epsilon: "艾普西隆",
2188
+ zeta: "泽塔",
2189
+ eta: "伊塔",
2190
+ theta: "西塔",
2191
+ iota: "约塔",
2192
+ kappa: "卡帕",
2193
+ lambda: "拉姆达",
2194
+ mu: "缪",
2195
+ nu: "纽",
2196
+ xi: "克西",
2197
+ pi: "派",
2198
+ rho: "柔",
2199
+ sigma: "西格马",
2200
+ tau: "陶",
2201
+ phi: "斐",
2202
+ chi: "凯",
2203
+ psi: "普西",
2204
+ omega: "欧米伽"
2205
+ };
2206
+ /** 读取 `{...}` 花括号组(从 openIdx 的下一个字符起,返回内容与结束下标)。 */
2207
+ function readGroup(s, i) {
2208
+ if (s[i] !== "{") return null;
2209
+ let depth = 0;
2210
+ for (let j = i; j < s.length; j++) if (s[j] === "{") depth++;
2211
+ else if (s[j] === "}") {
2212
+ depth--;
2213
+ if (depth === 0) return {
2214
+ body: s.slice(i + 1, j),
2215
+ end: j + 1
2216
+ };
2217
+ }
2218
+ return null;
2219
+ }
2220
+ /** 单 token(花括号组或单字符)读取:返回 {body, end}。 */
2221
+ function readAtom(s, i) {
2222
+ const g = readGroup(s, i);
2223
+ if (g) return g;
2224
+ return {
2225
+ body: s[i] ?? "",
2226
+ end: i + 1
2227
+ };
2228
+ }
2229
+ /** LaTeX 片段 → 中文口语(递归:命令参数体内再走一遍)。 */
2230
+ function mathToSpokenZH(tex) {
2231
+ let s = tex;
2232
+ s = s.replace(/\\(?:left|right|displaystyle|limits)\b/g, " ");
2233
+ s = s.replace(/\\[,;!\s]|\\quad|\\qquad|~/g, " ");
2234
+ for (let guard = 0; guard < 12; guard++) {
2235
+ let next = s;
2236
+ next = next.replace(/\\[dt]?frac(?![A-Za-z])/g, "\\frac");
2237
+ {
2238
+ let i;
2239
+ while ((i = next.indexOf("\\frac")) >= 0) {
2240
+ const a = readAtom(next, i + 5);
2241
+ const b = readAtom(next, a.end);
2242
+ next = next.slice(0, i) + `${a.body} 分之 ${b.body} ` + next.slice(b.end);
2243
+ }
2244
+ }
2245
+ {
2246
+ let i;
2247
+ while ((i = next.indexOf("\\binom")) >= 0) {
2248
+ const a = readAtom(next, i + 6);
2249
+ const b = readAtom(next, a.end);
2250
+ next = next.slice(0, i) + `${a.body} 取 ${b.body} ` + next.slice(b.end);
2251
+ }
2252
+ }
2253
+ next = next.replace(/\\sqrt\s*\[( {[^{}]*} |[^\s\\]+)\s*\]\s*/g, (_m, n) => `${n.trim()} 次根号 `);
2254
+ {
2255
+ let i;
2256
+ while ((i = next.indexOf("\\sqrt")) >= 0) {
2257
+ const a = readAtom(next, i + 5);
2258
+ next = next.slice(0, i) + `根号 ${a.body} ` + next.slice(a.end);
2259
+ }
2260
+ }
2261
+ next = next.replace(/\\(?:mathbf|mathbb|mathrm|mathit|boldsymbol|text|operatorname)\s*/g, (m) => m.startsWith("\\text") ? "" : " ");
2262
+ if (next === s) {
2263
+ s = next;
2264
+ break;
2265
+ }
2266
+ s = next;
2267
+ }
2268
+ for (let guard = 0; guard < 8; guard++) {
2269
+ const next = s.replace(/(\\frac|[\w)\]}]|\s)\s*\^\s*(\{[^{}]*\}|[^\s])|(\\frac|[\w)\]}]| )\s*_\s*(\{[^{}]*\}|[^\s])/g, (_m, base1, sup, base2, sub) => {
2270
+ if (sup !== void 0) return `${base1} 的 ${sup.replace(/[{}]/g, "")} 次方`;
2271
+ return `${base2} 下标 ${sub.replace(/[{}]/g, "")}`;
2272
+ });
2273
+ if (next === s) break;
2274
+ s = next;
2275
+ }
2276
+ for (const [cmd, zh] of SYMBOLS) s = s.split(cmd).join(` ${zh} `);
2277
+ s = s.replace(/\\([A-Za-z]+)\b/g, (_m, name) => ` ${GREEK[name] ?? name} `);
2278
+ s = s.replace(/[{}]/g, " ");
2279
+ return s.replace(/\s+/g, " ").trim();
2280
+ }
2281
+ /** 句子级入口:把 `$..$`/`$$..$$` 段替换为口语;段外文本逐字节不变。 */
2282
+ function speakMathInSentence(sentence) {
2283
+ return sentence.replace(/\$\$([^$]+)\$\$|\$([^$]+)\$/g, (_m, block, inline) => {
2284
+ const spoken = mathToSpokenZH(block ?? inline ?? "");
2285
+ return spoken ? ` ${spoken} ` : "";
2286
+ });
2287
+ }
2288
+ //#endregion
2289
+ //#region src/client/readaloud.ts
2290
+ const sleep = (ms) => new Promise((resolve) => {
2291
+ setTimeout(resolve, ms);
2292
+ });
2293
+ var ReadAloudController = class {
2294
+ sentences;
2295
+ primary;
2296
+ fallback;
2297
+ notify;
2298
+ index = 0;
2299
+ stopped = false;
2300
+ paused = false;
2301
+ engine = null;
2302
+ degraded = false;
2303
+ run = null;
2304
+ constructor(sentences, primary, fallback, notify) {
2305
+ this.sentences = sentences;
2306
+ this.primary = primary;
2307
+ this.fallback = fallback;
2308
+ this.notify = notify;
2309
+ }
2310
+ emit(state) {
2311
+ this.notify?.({
2312
+ index: this.index,
2313
+ total: this.sentences.length,
2314
+ engine: this.engine,
2315
+ state,
2316
+ degraded: this.degraded
2317
+ });
2318
+ }
2319
+ /** Speak the whole queue; the returned promise settles when done or stopped. */
2320
+ start() {
2321
+ if (this.run !== null) return this.run;
2322
+ this.stopped = false;
2323
+ this.run = (async () => {
2324
+ this.engine = "edge";
2325
+ this.emit("speaking");
2326
+ while (!this.stopped && this.index < this.sentences.length) {
2327
+ const text = this.sentences[this.index];
2328
+ try {
2329
+ await this.speakOn(text);
2330
+ } catch {
2331
+ if (this.stopped) return;
2332
+ if (this.engine === "edge") {
2333
+ this.degraded = true;
2334
+ this.engine = "system";
2335
+ this.emit("speaking");
2336
+ try {
2337
+ await this.speakOn(text);
2338
+ } catch {
2339
+ this.emit("idle");
2340
+ return;
2341
+ }
2342
+ } else {
2343
+ this.emit("idle");
2344
+ return;
2345
+ }
2346
+ }
2347
+ if (this.stopped) return;
2348
+ this.index += 1;
2349
+ this.emit("speaking");
2350
+ }
2351
+ this.emit("idle");
2352
+ })();
2353
+ return this.run;
2354
+ }
2355
+ async speakOn(text) {
2356
+ await (this.engine === "system" ? this.fallback : this.primary).speak(text);
2357
+ while (this.paused && !this.stopped) await sleep(120);
2358
+ }
2359
+ pause() {
2360
+ if (this.stopped || this.paused) return;
2361
+ this.paused = true;
2362
+ (this.engine === "system" ? this.fallback : this.primary).pause();
2363
+ this.emit("paused");
2364
+ }
2365
+ resume() {
2366
+ if (!this.paused) return;
2367
+ this.paused = false;
2368
+ (this.engine === "system" ? this.fallback : this.primary).resume();
2369
+ this.emit("speaking");
2370
+ }
2371
+ /** Stop everything; the controller is single-use — build a fresh one to replay. */
2372
+ stop() {
2373
+ this.stopped = true;
2374
+ this.paused = false;
2375
+ this.primary.cancel();
2376
+ this.fallback.cancel();
2377
+ this.emit("idle");
2378
+ }
2379
+ get status() {
2380
+ return {
2381
+ index: this.index,
2382
+ total: this.sentences.length,
2383
+ engine: this.engine,
2384
+ state: this.stopped ? "idle" : this.paused ? "paused" : "speaking",
2385
+ degraded: this.degraded
2386
+ };
2387
+ }
2388
+ };
2389
+ //#endregion
1908
2390
  //#region src/client/views.tsx
1909
2391
  /**
1910
2392
  * The study tab: one `conversation.view` entry rendering the whole plugin —
@@ -2149,7 +2631,7 @@ window.__ModuleLoader__.load({
2149
2631
  }
2150
2632
  /** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
2151
2633
  function StudyTab({ inputActions, ctx, ...standard }) {
2152
- const { data, activate, setMode, setFocus, searchLessons, deleteCourse, bindLessonSession } = useStudy();
2634
+ const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession } = useStudy();
2153
2635
  const chatLegacy = standard.useChat?.((s) => s.legacy);
2154
2636
  const sessionSnapshot = standard.useSession?.((s) => s);
2155
2637
  const snapshot = pickTranscript(chatLegacy, sessionSnapshot);
@@ -2257,7 +2739,10 @@ window.__ModuleLoader__.load({
2257
2739
  setMode,
2258
2740
  send,
2259
2741
  snapshot
2260
- }), (0, react.createElement)(BlackboardColumn, { data })));
2742
+ }), (0, react.createElement)(BlackboardColumn, {
2743
+ data,
2744
+ deleteNote
2745
+ })));
2261
2746
  }
2262
2747
  /** Left column: course management (pick/delete/search/import), lesson tree, due box. */
2263
2748
  function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
@@ -2600,10 +3085,108 @@ window.__ModuleLoader__.load({
2600
3085
  }
2601
3086
  }, s.label))) : null, (0, react.createElement)(ActionError, { error }));
2602
3087
  }
3088
+ /** Browser speechSynthesis engine — the offline/endpoint-gone fallback voice. */
3089
+ function systemSpeechEngine() {
3090
+ return {
3091
+ speak(text) {
3092
+ return new Promise((resolve, reject) => {
3093
+ const synth = typeof window === "undefined" ? void 0 : window.speechSynthesis;
3094
+ if (synth === void 0) {
3095
+ reject(/* @__PURE__ */ new Error("no speechSynthesis"));
3096
+ return;
3097
+ }
3098
+ const utterance = new SpeechSynthesisUtterance(text);
3099
+ utterance.lang = "zh-CN";
3100
+ utterance.onend = () => resolve();
3101
+ utterance.onerror = () => reject(/* @__PURE__ */ new Error("speechSynthesis failed"));
3102
+ synth.speak(utterance);
3103
+ });
3104
+ },
3105
+ pause() {
3106
+ window.speechSynthesis?.pause();
3107
+ },
3108
+ resume() {
3109
+ window.speechSynthesis?.resume();
3110
+ },
3111
+ cancel() {
3112
+ window.speechSynthesis?.cancel();
3113
+ }
3114
+ };
3115
+ }
3116
+ /** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>. */
3117
+ function audioSpeechEngine(fetchTts) {
3118
+ let audio = null;
3119
+ return {
3120
+ speak(text) {
3121
+ return fetchTts(text).then((buf) => new Promise((resolve, reject) => {
3122
+ audio = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" })));
3123
+ audio.onended = () => resolve();
3124
+ audio.onerror = () => reject(/* @__PURE__ */ new Error("audio playback failed"));
3125
+ audio.play().catch(reject);
3126
+ }));
3127
+ },
3128
+ pause() {
3129
+ audio?.pause();
3130
+ },
3131
+ resume() {
3132
+ audio?.play();
3133
+ },
3134
+ cancel() {
3135
+ audio?.pause();
3136
+ audio = null;
3137
+ }
3138
+ };
3139
+ }
2603
3140
  /** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
2604
- function BlackboardColumn({ data }) {
3141
+ function BlackboardColumn({ data, deleteNote }) {
2605
3142
  const lesson = data?.lesson ?? null;
3143
+ const { tts } = useStudy();
2606
3144
  const [pane, setPane] = (0, react.useState)("teach");
3145
+ const [error, setError] = (0, react.useState)(null);
3146
+ const [read, setRead] = (0, react.useState)(null);
3147
+ const readCtl = (0, react.useRef)(null);
3148
+ const [readError, setReadError] = (0, react.useState)(null);
3149
+ const startReading = () => {
3150
+ if (lesson === null || lesson.speechText.trim() === "") return;
3151
+ readCtl.current?.stop();
3152
+ const controller = new ReadAloudController(speechSentencesOf(lesson.speechText).map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text)), systemSpeechEngine(), (s) => {
3153
+ setRead(s);
3154
+ if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
3155
+ });
3156
+ readCtl.current = controller;
3157
+ setReadError(null);
3158
+ controller.start().catch((err) => {
3159
+ setReadError(err instanceof Error ? err.message : String(err));
3160
+ });
3161
+ };
3162
+ const stopReading = () => {
3163
+ readCtl.current?.stop();
3164
+ setRead(null);
3165
+ setReadError(null);
3166
+ };
3167
+ const [armedNote, setArmedNote] = (0, react.useState)(null);
3168
+ (0, react.useEffect)(() => {
3169
+ if (armedNote === null) return;
3170
+ const disarm = () => {
3171
+ setArmedNote(null);
3172
+ };
3173
+ const onKey = (e) => {
3174
+ if (e.key === "Escape") disarm();
3175
+ };
3176
+ window.addEventListener("click", disarm);
3177
+ window.addEventListener("keydown", onKey);
3178
+ return () => {
3179
+ window.removeEventListener("click", disarm);
3180
+ window.removeEventListener("keydown", onKey);
3181
+ };
3182
+ }, [armedNote]);
3183
+ const fire = (action) => {
3184
+ action.then(() => {
3185
+ setError(null);
3186
+ }, (err) => {
3187
+ setError(err instanceof Error ? err.message : String(err));
3188
+ });
3189
+ };
2607
3190
  const proseRef = (0, react.useRef)(null);
2608
3191
  const diagRef = (0, react.useRef)(null);
2609
3192
  (0, react.useEffect)(() => {
@@ -2643,7 +3226,35 @@ window.__ModuleLoader__.load({
2643
3226
  onClick: () => {
2644
3227
  setPane("cmap");
2645
3228
  }
2646
- }, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", {
3229
+ }, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
3230
+ className: "lks-btn ghost",
3231
+ style: {
3232
+ padding: "3px 8px",
3233
+ fontSize: "12.5px",
3234
+ flex: "none"
3235
+ },
3236
+ title: read !== null && read.state === "speaking" ? tr("read.pause") : tr("read.play"),
3237
+ onClick: () => {
3238
+ if (read !== null && read.state === "speaking") {
3239
+ readCtl.current?.pause();
3240
+ return;
3241
+ }
3242
+ if (read !== null && read.state === "paused") {
3243
+ readCtl.current?.resume();
3244
+ return;
3245
+ }
3246
+ startReading();
3247
+ }
3248
+ }, (0, react.createElement)(IconPlayOutline16, { size: 12 }), read !== null && read.state === "speaking" ? tr("read.pause") : read !== null && read.state === "paused" ? tr("read.resume") : tr("read.play")), read !== null ? (0, react.createElement)("button", {
3249
+ className: "lks-btn ghost",
3250
+ style: {
3251
+ padding: "3px 8px",
3252
+ fontSize: "12.5px",
3253
+ flex: "none"
3254
+ },
3255
+ title: tr("read.stop"),
3256
+ onClick: stopReading
3257
+ }, 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) : null, pane === "teach" ? (0, react.createElement)("div", {
2647
3258
  className: "lks-prose",
2648
3259
  ref: proseRef,
2649
3260
  dangerouslySetInnerHTML: { __html: lesson.html }
@@ -2659,11 +3270,24 @@ window.__ModuleLoader__.load({
2659
3270
  }, (0, react.createElement)("div", { className: "lks-zone-h" }, tr(labelKey)), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
2660
3271
  key: n.id,
2661
3272
  className: "lks-note"
2662
- }, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("div", { className: "lks-note-title" }, n.title), (0, react.createElement)("div", {
3273
+ }, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("button", {
3274
+ className: `lks-note-del${armedNote === n.id ? " armed" : ""}`,
3275
+ title: armedNote === n.id ? tr("note.delete.confirm") : tr("note.delete"),
3276
+ "aria-label": armedNote === n.id ? tr("note.delete.confirm") : tr("note.delete"),
3277
+ onClick: (e) => {
3278
+ e.stopPropagation();
3279
+ if (armedNote !== n.id) {
3280
+ setArmedNote(n.id);
3281
+ return;
3282
+ }
3283
+ setArmedNote(null);
3284
+ fire(deleteNote(lesson.lessonId, n.id));
3285
+ }
3286
+ }, (0, react.createElement)(IconTrashOutline16, { size: 12 })), (0, react.createElement)("div", { className: "lks-note-title" }, n.title), (0, react.createElement)("div", {
2663
3287
  className: "lks-note-text",
2664
3288
  dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
2665
3289
  }), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `“${n.quote}”`) : null))))));
2666
- return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body);
3290
+ return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body, (0, react.createElement)(ActionError, { error }));
2667
3291
  }
2668
3292
  //#endregion
2669
3293
  //#region src/client/starter.tsx