dsh-plugin-lookatstudy 0.25.2 → 0.25.3

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
@@ -391,7 +391,16 @@ window.__ModuleLoader__.load({
391
391
  .lks-ui .lks14-emptycard-title{font-size:1rem;font-weight:800;color:var(--ink-strong)}
392
392
  .lks-ui .lks14-emptycard-hint{font-size:.825rem;color:var(--ink-muted);line-height:1.6}
393
393
  /* B5: notebook tab capsule + 960px reading column */
394
- .lks-ui .lks14-notebody{margin:0 auto;max-width:960px;width:100%;padding:12px 20px 24px;box-sizing:border-box}
394
+ .lks-ui .lks14-notebody{margin:0;width:100%;padding:12px 20px 24px;box-sizing:border-box}
395
+ /* 0.25.3 (owner): the view tabs pin to the note column's top — full-bleed
396
+ negative margins cancel the notebody padding so the bar covers the whole
397
+ column width while content scrolls underneath */
398
+ .lks-ui .lks14-viewtabs{position:sticky;top:0;z-index:20;margin:0 -20px 12px;padding:8px 20px 6px;background:var(--surface-2);border-bottom:1px solid var(--border-faint)}
399
+ .lks-ui .lks14-viewtab{display:inline-flex;align-items:center;gap:5px}
400
+ /* 0.25.3 karaoke: the speaking sentence highlight (CSS Custom Highlight API
401
+ registration; the span class is the no-Highlight-API fallback) */
402
+ ::highlight(lks-reading){background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong)}
403
+ .lks-ui .lks14-reading-mark{background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong);border-radius:3px}
395
404
  /* #10-followup: the reading column is height:auto (the note col scrolls), so
396
405
  .lks14-board's height:100% resolved against a content-sized parent and the
397
406
  stage collapsed to a ~90px sliver — the never-upscale contain fit then
@@ -1418,6 +1427,38 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1418
1427
  fill: "currentColor"
1419
1428
  }));
1420
1429
  }
1430
+ /** IconBookOutline16 — plugin-original learning-domain companion (teach tab). */
1431
+ function IconBookOutline16({ size = 16, className }) {
1432
+ return (0, react.createElement)("svg", {
1433
+ width: size,
1434
+ height: size,
1435
+ className,
1436
+ viewBox: "0 0 16 16",
1437
+ fill: "none",
1438
+ xmlns: "http://www.w3.org/2000/svg"
1439
+ }, (0, react.createElement)("path", {
1440
+ fillRule: "evenodd",
1441
+ clipRule: "evenodd",
1442
+ d: "M2 2.5C2 1.67157 2.67157 1 3.5 1H12.5C13.3284 1 14 1.67157 14 2.5V12.5C14 13.3284 13.3284 14 12.5 14H3.5C2.67157 14 2 13.3284 2 12.5V2.5ZM3.5 2C3.22386 2 3 2.22386 3 2.5V12.5C3 12.7761 3.22386 13 3.5 13H4V3H3.5ZM5 3V13H12.5C12.7761 13 13 12.7761 13 12.5V2.5C13 2.22386 12.7761 2 12.5 2H5ZM6 4.5H11.5V5.5H6V4.5ZM6 7H11.5V8H6V7ZM6 9.5H10V10.5H6V9.5Z",
1443
+ fill: "currentColor"
1444
+ }));
1445
+ }
1446
+ /** IconPenOutline16 — plugin-original learning-domain companion (notes tab). */
1447
+ function IconPenOutline16({ size = 16, className }) {
1448
+ return (0, react.createElement)("svg", {
1449
+ width: size,
1450
+ height: size,
1451
+ className,
1452
+ viewBox: "0 0 16 16",
1453
+ fill: "none",
1454
+ xmlns: "http://www.w3.org/2000/svg"
1455
+ }, (0, react.createElement)("path", {
1456
+ fillRule: "evenodd",
1457
+ clipRule: "evenodd",
1458
+ d: "M11.4503 1.65669C12.0849 1.02209 13.1145 1.02209 13.7491 1.65669L14.3433 2.25091C14.9779 2.88551 14.9779 3.91507 14.3433 4.54967L6.13604 12.7569L2.33156 13.7212C1.92166 13.8248 1.54569 13.4488 1.64934 13.0389L2.61359 9.23442L10.8208 1.02719L11.4503 1.65669ZM11.5317 2.66034L3.61621 10.5758L3.04325 12.8379L4.3433 12.5094L12.2987 4.55397L11.5317 2.78695V2.66034Z",
1459
+ fill: "currentColor"
1460
+ }));
1461
+ }
1421
1462
  /** IconGoalOutline16 */
1422
1463
  function IconGoalOutline16({ size = 16, className }) {
1423
1464
  return (0, react.createElement)("svg", {
@@ -4506,6 +4547,21 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4506
4547
  s = s.replace(/`[^`\n]*`/g, "");
4507
4548
  s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
4508
4549
  s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
4550
+ s = s.replace(/\[\]\([^)\n]*\)/g, "");
4551
+ s = s.replace(/<br\s*\/?>/gi, "\n");
4552
+ s = s.replace(/<https?:\/\/[^>\s]+>/g, "");
4553
+ s = s.replace(/https?:\/\/[^\s)\]]+/g, "");
4554
+ s = s.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "\n");
4555
+ s = s.replace(/<[^>\n]+>/g, "");
4556
+ s = s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#0?39;/g, "'").replace(/&nbsp;/g, " ").replace(/&#(\d+);/g, (_m, d) => {
4557
+ try {
4558
+ return String.fromCodePoint(Number(d));
4559
+ } catch {
4560
+ return "";
4561
+ }
4562
+ }).replace(/&[a-z]+;/gi, "");
4563
+ s = s.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1");
4564
+ s = s.replace(/\[\^[^\]]*\]/g, "");
4509
4565
  s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
4510
4566
  s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
4511
4567
  s = s.replace(/^[ \t]*>[ \t]?/gm, "");
@@ -4605,6 +4661,203 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4605
4661
  };
4606
4662
  }
4607
4663
  //#endregion
4664
+ //#region src/client/reading-mark.ts
4665
+ /**
4666
+ * Reading karaoke — the sentence highlight + follow for the teach prose's
4667
+ * read-aloud. Ported subset of upstream LookatStudy src/renderer/lib/
4668
+ * highlightText.ts (MIT License, https://github.com/Kaiji-Z/LookatStudy):
4669
+ * the v9 canonical whole-sentence alignment (punctuation/full-width
4670
+ * differences collapse; edge extension swallows opening quotes and sentence
4671
+ * punctuation so the highlight always covers the visible sentence), the v8
4672
+ * CSS Custom Highlight API registration (zero DOM mutation — span wrapping
4673
+ * fights React's reconciliation on every sentence tick; the span stays only
4674
+ * as a fallback), and the v0.18.1 line-box centering follow (a 13k-char
4675
+ * paragraph renders 3000px tall — element-box scrollIntoView pins at the
4676
+ * paragraph head while the highlight walks on; the sentence's own first-line
4677
+ * rect is what must stay in view). Not ported: companion avoidance, played
4678
+ * -prefix splitting, display-group spans (companion is retired in this
4679
+ * plugin; the controller's sentence index is already authoritative).
4680
+ * @module dsh-plugin-lookatstudy/client/reading-mark
4681
+ */
4682
+ const WORD_CHAR_RE = /[0-9A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/;
4683
+ const OPEN_EDGE = "「『(([【<《\"'“‘";
4684
+ const CLOSE_EDGE = "。』」))]】>》\"'”’!!??…,,、;;::";
4685
+ /** One char → canonical form (full-width→half-width, lowercase) or null when
4686
+ * the char is punctuation/space/symbol (all filtered). Pure (upstream). */
4687
+ function canonChar(ch) {
4688
+ const c = ch.codePointAt(0);
4689
+ let s = ch;
4690
+ if (c >= 65281 && c <= 65374) s = String.fromCharCode(c - 65248);
4691
+ return WORD_CHAR_RE.test(s) ? s.toLowerCase() : null;
4692
+ }
4693
+ /** Text → canonical string + canonical→original offset map. Pure (upstream). */
4694
+ function canonicalSpeechIndex(text) {
4695
+ let canon = "";
4696
+ const map = [];
4697
+ for (let i = 0; i < text.length; i++) {
4698
+ const c = canonChar(text[i]);
4699
+ if (c !== null) {
4700
+ canon += c;
4701
+ map.push(i);
4702
+ }
4703
+ }
4704
+ return {
4705
+ canon,
4706
+ map
4707
+ };
4708
+ }
4709
+ /**
4710
+ * Align one speech sentence against the prose's full text (pure). Whole
4711
+ * canonical indexOf first (the common case — punctuation/width differences
4712
+ * vanish); on failure a token-interval match lets displayed-but-unspoken
4713
+ * runs (inline code, table pipes) sit inside the match as gaps; the matched
4714
+ * span then extends over opening quotes before and sentence punctuation
4715
+ * after, so the highlight covers the complete visible sentence.
4716
+ * @returns [start, end) offsets into `text`, or null.
4717
+ */
4718
+ function matchSentenceAligned(text, sentence, from = 0) {
4719
+ const { canon, map } = canonicalSpeechIndex(text);
4720
+ const sen = canonicalSpeechIndex(sentence).canon;
4721
+ if (sen === "" || canon === "") return null;
4722
+ let hit = canon.indexOf(sen, Math.min(from, canon.length));
4723
+ if (hit === -1) hit = canon.indexOf(sen);
4724
+ let start;
4725
+ let end;
4726
+ if (hit !== -1) {
4727
+ start = map[hit];
4728
+ end = (map[hit + sen.length - 1] ?? map[map.length - 1]) + 1;
4729
+ } else {
4730
+ const head = sen.slice(0, Math.min(2, sen.length));
4731
+ const tail = sen.slice(-Math.min(2, sen.length));
4732
+ const h = canon.indexOf(head, Math.min(from, canon.length));
4733
+ if (h === -1) return null;
4734
+ const t = tail === "" ? h + head.length - 1 : canon.lastIndexOf(tail, Math.min(canon.length - 1, h + sen.length + 128));
4735
+ if (t < h + head.length - 1) return null;
4736
+ start = map[h];
4737
+ end = (map[t + tail.length - 1] ?? map[map.length - 1]) + 1;
4738
+ }
4739
+ while (start > 0 && OPEN_EDGE.includes(text[start - 1])) start--;
4740
+ while (end < text.length && CLOSE_EDGE.includes(text[end])) end++;
4741
+ return [start, end];
4742
+ }
4743
+ function buildTextModel(container) {
4744
+ const nodes = [];
4745
+ let text = "";
4746
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode(node) {
4747
+ let p = node.parentNode;
4748
+ while (p !== null && p !== container) {
4749
+ if (p instanceof HTMLElement && (p.tagName === "SCRIPT" || p.tagName === "STYLE" || p.classList.contains("lks14-reading-mark"))) return NodeFilter.FILTER_REJECT;
4750
+ p = p.parentNode;
4751
+ }
4752
+ return NodeFilter.FILTER_ACCEPT;
4753
+ } });
4754
+ for (let n = walker.nextNode(); n !== null; n = walker.nextNode()) {
4755
+ const t = n;
4756
+ nodes.push({
4757
+ node: t,
4758
+ start: text.length
4759
+ });
4760
+ text += t.data;
4761
+ }
4762
+ return {
4763
+ text,
4764
+ nodes
4765
+ };
4766
+ }
4767
+ function offsetsToRange(model, start, end) {
4768
+ const range = document.createRange();
4769
+ let setStart = false;
4770
+ let setEnd = false;
4771
+ for (const { node, start: ns } of model.nodes) {
4772
+ const len = node.data.length;
4773
+ if (!setStart && start >= ns && start <= ns + len) {
4774
+ range.setStart(node, start - ns);
4775
+ setStart = true;
4776
+ }
4777
+ if (!setEnd && end >= ns && end <= ns + len) {
4778
+ range.setEnd(node, end - ns);
4779
+ setEnd = true;
4780
+ break;
4781
+ }
4782
+ }
4783
+ return setStart && setEnd ? range : null;
4784
+ }
4785
+ const READING_HIGHLIGHT_NAME = "lks-reading";
4786
+ const READING_MARK_CLASS = "lks14-reading-mark";
4787
+ const readingCursors = /* @__PURE__ */ new WeakMap();
4788
+ /** Drop the registry highlight outright (unmount/stop; DOM may be gone). */
4789
+ function clearReadingHighlightRegistry() {
4790
+ const cssLike = CSS;
4791
+ try {
4792
+ cssLike.highlights?.delete(READING_HIGHLIGHT_NAME);
4793
+ } catch {}
4794
+ }
4795
+ /** Remove the reading mark (registry + fallback spans) from one container. */
4796
+ function clearReadingMark(container) {
4797
+ clearReadingHighlightRegistry();
4798
+ container.querySelectorAll(`.${READING_MARK_CLASS}`).forEach((m) => {
4799
+ const parent = m.parentNode;
4800
+ if (parent !== null) {
4801
+ while (m.firstChild !== null) parent.insertBefore(m.firstChild, m);
4802
+ parent.removeChild(m);
4803
+ }
4804
+ });
4805
+ container.normalize();
4806
+ }
4807
+ /** A fresh read restarts the monotonic cursor. */
4808
+ function resetReadingCursor(container) {
4809
+ readingCursors.set(container, 0);
4810
+ }
4811
+ /**
4812
+ * Highlight the currently spoken sentence in the prose. Returns the marked
4813
+ * Range (null when the sentence cannot be aligned — the read keeps going,
4814
+ * only the highlight skips).
4815
+ */
4816
+ function markReadingSentence(container, sentence) {
4817
+ clearReadingMark(container);
4818
+ const trimmed = sentence.trim();
4819
+ if (trimmed === "") return null;
4820
+ const model = buildTextModel(container);
4821
+ const from = readingCursors.get(container) ?? 0;
4822
+ let m = matchSentenceAligned(model.text, trimmed, from);
4823
+ if (m === null && from > 0) m = matchSentenceAligned(model.text, trimmed, 0);
4824
+ if (m === null) return null;
4825
+ readingCursors.set(container, m[1]);
4826
+ const range = offsetsToRange(model, m[0], m[1]);
4827
+ if (range === null) return null;
4828
+ const cssLike = CSS;
4829
+ if (typeof Highlight !== "undefined" && cssLike.highlights !== void 0) try {
4830
+ cssLike.highlights.set(READING_HIGHLIGHT_NAME, new Highlight(range));
4831
+ return range;
4832
+ } catch {}
4833
+ const mark = document.createElement("span");
4834
+ mark.className = READING_MARK_CLASS;
4835
+ try {
4836
+ range.surroundContents(mark);
4837
+ return range;
4838
+ } catch {
4839
+ return range;
4840
+ }
4841
+ }
4842
+ /**
4843
+ * Keep the marked sentence's FIRST-LINE rect inside the scroll viewport
4844
+ * (upstream v0.18.1): element-box centering pins on 3000px paragraphs; the
4845
+ * sentence's own line box is what the eye follows. Smooth-centers only when
4846
+ * the line leaves a 48px band.
4847
+ */
4848
+ function centerReadingRangeInView(range, scroller) {
4849
+ const rects = range.getClientRects();
4850
+ const line = rects.length > 0 && rects[0].height > 0 ? rects[0] : null;
4851
+ if (line === null) return;
4852
+ if (scroller === null) return;
4853
+ const sTop = scroller.getBoundingClientRect().top;
4854
+ const rel = line.top - sTop;
4855
+ if (rel < 48 || rel > scroller.clientHeight - 48) scroller.scrollTo({
4856
+ top: scroller.scrollTop + rel - scroller.clientHeight / 2,
4857
+ behavior: "smooth"
4858
+ });
4859
+ }
4860
+ //#endregion
4608
4861
  //#region src/vendor/math-speech.ts
4609
4862
  /**
4610
4863
  * Vendored from LookatStudy shared/math-speech.ts (MIT License,
@@ -10121,10 +10374,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10121
10374
  }, []);
10122
10375
  const [enhanceTick, setEnhanceTick] = (0, react.useState)(0);
10123
10376
  const [rateBusy, setRateBusy] = (0, react.useState)(false);
10377
+ const speakText = lesson !== null && langView === "translation" && lesson.translationSpeechText !== void 0 ? lesson.translationSpeechText : lesson?.speechText ?? "";
10124
10378
  const startReading = () => {
10125
- if (lesson === null || lesson.speechText.trim() === "") return;
10379
+ if (lesson === null || speakText.trim() === "") return;
10126
10380
  readCtl.current?.stop();
10127
- const sentences = speechSentencesOf(lesson.speechText);
10381
+ const sentences = speechSentencesOf(speakText);
10128
10382
  const voice = storedTtsVoice();
10129
10383
  const prefetch = /* @__PURE__ */ new Map();
10130
10384
  let currentAudio = null;
@@ -10206,6 +10460,38 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10206
10460
  readCtl.current?.stop();
10207
10461
  setRead(null);
10208
10462
  };
10463
+ (0, react.useEffect)(() => {
10464
+ const prose = proseRef.current;
10465
+ if (prose === null) return;
10466
+ if (read === null || read.state !== "speaking" || tab !== "teach") {
10467
+ clearReadingMark(prose);
10468
+ return;
10469
+ }
10470
+ if (read.index === 0) resetReadingCursor(prose);
10471
+ const sentence = speechSentencesOf(speakText)[read.index];
10472
+ if (sentence === void 0) return;
10473
+ const timer = setTimeout(() => {
10474
+ const rg = markReadingSentence(prose, sentence);
10475
+ if (rg !== null) centerReadingRangeInView(rg, prose.closest(".lks14-note"));
10476
+ }, 30);
10477
+ return () => {
10478
+ clearTimeout(timer);
10479
+ };
10480
+ }, [
10481
+ read?.index,
10482
+ read?.state,
10483
+ tab,
10484
+ teachHtml
10485
+ ]);
10486
+ (0, react.useEffect)(() => {
10487
+ if (read === null && proseRef.current !== null) clearReadingMark(proseRef.current);
10488
+ }, [lesson?.lessonId]);
10489
+ (0, react.useEffect)(() => () => {
10490
+ clearReadingHighlightRegistry();
10491
+ }, []);
10492
+ (0, react.useEffect)(() => {
10493
+ if (read !== null) stopReading();
10494
+ }, [langView]);
10209
10495
  (0, react.useEffect)(() => {
10210
10496
  if (tab !== "cmap" || diagRef.current === null || lesson === null) return;
10211
10497
  const el = diagRef.current;
@@ -10239,7 +10525,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10239
10525
  onClick: () => {
10240
10526
  setTab("teach");
10241
10527
  }
10242
- }, tr("viewtab.teach")), (0, react.createElement)("button", {
10528
+ }, (0, react.createElement)(IconBookOutline16, { size: 13 }), tr("viewtab.teach")), (0, react.createElement)("button", {
10243
10529
  className: `lks14-viewtab${tab === "cmap" ? " on" : ""}`,
10244
10530
  "aria-pressed": String(tab === "cmap"),
10245
10531
  "data-tooltip": tr("viewtab.cmap.title"),
@@ -10256,7 +10542,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10256
10542
  setUnseenTick(Date.now());
10257
10543
  }
10258
10544
  }
10259
- }, tr("bb.notes"), unseen.length > 0 ? (0, react.createElement)("span", { className: "lks-viewtab-badge" }, String(unseen.length)) : null), (0, react.createElement)("button", {
10545
+ }, (0, react.createElement)(IconPenOutline16, { size: 13 }), tr("bb.notes"), unseen.length > 0 ? (0, react.createElement)("span", { className: "lks-viewtab-badge" }, String(unseen.length)) : null), (0, react.createElement)("button", {
10260
10546
  className: `lks14-viewtab${tab === "board" ? " on" : ""}`,
10261
10547
  "aria-pressed": String(tab === "board"),
10262
10548
  "data-tooltip": tr("bb.board.empty"),
@@ -10331,7 +10617,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10331
10617
  },
10332
10618
  "data-tooltip": tr("read.stop"),
10333
10619
  onClick: stopReading
10334
- }, 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)(ContentBoundary, {
10620
+ }, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(speakText)[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)(ContentBoundary, {
10335
10621
  content: lesson.markdown ?? "",
10336
10622
  boundaryKey: `prose-${lesson.lessonId}`
10337
10623
  }, (0, react.createElement)("div", {