@pie-players/pie-assessment-toolkit 0.3.50 → 0.3.52

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.
Files changed (34) hide show
  1. package/README.md +25 -0
  2. package/dist/components/ItemToolBar.custom-element.js +98 -14
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +293 -48
  4. package/dist/components/SectionToolBar.custom-element.js +98 -14
  5. package/dist/context/assessment-toolkit-context.d.ts +2 -0
  6. package/dist/context/assessment-toolkit-context.js.map +1 -1
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +2 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/services/HighlightCoordinator.d.ts +6 -0
  11. package/dist/services/HighlightCoordinator.js +20 -5
  12. package/dist/services/HighlightCoordinator.js.map +1 -1
  13. package/dist/services/TTSService.d.ts +19 -0
  14. package/dist/services/TTSService.js +238 -35
  15. package/dist/services/TTSService.js.map +1 -1
  16. package/dist/services/ToolkitCoordinator.d.ts +3 -2
  17. package/dist/services/ToolkitCoordinator.js.map +1 -1
  18. package/dist/services/createDefaultToolRegistry.d.ts +2 -1
  19. package/dist/services/createDefaultToolRegistry.js +2 -1
  20. package/dist/services/createDefaultToolRegistry.js.map +1 -1
  21. package/dist/services/interfaces.d.ts +15 -0
  22. package/dist/services/interfaces.js.map +1 -1
  23. package/dist/services/tool-config-defaults.d.ts +11 -0
  24. package/dist/services/tool-config-defaults.js +18 -0
  25. package/dist/services/tool-config-defaults.js.map +1 -1
  26. package/dist/services/tts/highlight-target-resolver.d.ts +17 -0
  27. package/dist/services/tts/highlight-target-resolver.js +2 -0
  28. package/dist/services/tts/highlight-target-resolver.js.map +1 -0
  29. package/dist/services/tts-runtime-config.d.ts +15 -1
  30. package/dist/services/tts-runtime-config.js +76 -10
  31. package/dist/services/tts-runtime-config.js.map +1 -1
  32. package/dist/tools/registrations/tts.js +6 -8
  33. package/dist/tools/registrations/tts.js.map +1 -1
  34. package/package.json +7 -7
@@ -74,6 +74,7 @@ export class TTSService {
74
74
  currentProvider = null;
75
75
  provider = null;
76
76
  highlightCoordinator = null;
77
+ highlightTargetResolverProvider = null;
77
78
  catalogResolver = null;
78
79
  state = PlaybackState.IDLE;
79
80
  ttsConfig = {};
@@ -84,6 +85,7 @@ export class TTSService {
84
85
  lastError = null;
85
86
  speakRunId = 0;
86
87
  currentBoundaryOffset = 0;
88
+ activeWordBoundaryOffset = 0;
87
89
  seekSegments = [];
88
90
  sentenceHighlightSegments = [];
89
91
  currentSeekSegmentIndex = 0;
@@ -209,6 +211,17 @@ export class TTSService {
209
211
  setHighlightCoordinator(coordinator) {
210
212
  this.highlightCoordinator = coordinator;
211
213
  }
214
+ /**
215
+ * Set a late-bound provider for optional host TTS highlight target remapping.
216
+ */
217
+ setHighlightTargetResolverProvider(provider) {
218
+ this.highlightTargetResolverProvider = provider;
219
+ return () => {
220
+ if (this.highlightTargetResolverProvider === provider) {
221
+ this.highlightTargetResolverProvider = null;
222
+ }
223
+ };
224
+ }
212
225
  /**
213
226
  * Set accessibility catalog resolver for spoken content
214
227
  *
@@ -220,6 +233,145 @@ export class TTSService {
220
233
  setCatalogResolver(resolver) {
221
234
  this.catalogResolver = resolver;
222
235
  }
236
+ getHighlightResolverRuntime() {
237
+ let provided = null;
238
+ try {
239
+ provided = this.highlightTargetResolverProvider?.() ?? null;
240
+ }
241
+ catch {
242
+ provided = null;
243
+ }
244
+ const providedContext = provided?.context ?? {};
245
+ const fallbackScope = typeof HTMLElement !== "undefined" &&
246
+ this.currentContentElement instanceof HTMLElement
247
+ ? this.currentContentElement
248
+ : null;
249
+ return {
250
+ context: {
251
+ ...providedContext,
252
+ scopeElement: providedContext.scopeElement ?? fallbackScope,
253
+ },
254
+ resolver: provided?.resolver ?? null,
255
+ };
256
+ }
257
+ getNodeElement(node) {
258
+ if (!node)
259
+ return null;
260
+ return node.nodeType === Node.ELEMENT_NODE
261
+ ? node
262
+ : node.parentElement;
263
+ }
264
+ isElementWithinScope(element, scope) {
265
+ if (!scope)
266
+ return true;
267
+ return element === scope || scope.contains(element);
268
+ }
269
+ isRangeWithinScope(range, scope) {
270
+ const startElement = this.getNodeElement(range.startContainer);
271
+ const endElement = this.getNodeElement(range.endContainer);
272
+ if (!startElement || !endElement)
273
+ return false;
274
+ return (this.isElementWithinScope(startElement, scope) &&
275
+ this.isElementWithinScope(endElement, scope));
276
+ }
277
+ resolveWordHighlightRange(nativeRange) {
278
+ const { context, resolver } = this.getHighlightResolverRuntime();
279
+ const scope = context.scopeElement ?? this.currentContentElement;
280
+ try {
281
+ const resolved = resolver?.resolveWordRange?.(nativeRange, context);
282
+ if (resolved && this.isRangeWithinScope(resolved, scope)) {
283
+ return { range: resolved, remapped: !sameRange(nativeRange, resolved) };
284
+ }
285
+ }
286
+ catch {
287
+ // Fail open: custom resolver failures must not interrupt TTS playback.
288
+ }
289
+ return { range: nativeRange, remapped: false };
290
+ }
291
+ resolveSentenceHighlightTargets(nativeRanges) {
292
+ const { context, resolver } = this.getHighlightResolverRuntime();
293
+ const scope = context.scopeElement ?? this.currentContentElement;
294
+ try {
295
+ const resolved = resolver?.resolveSentenceRanges?.(nativeRanges, context);
296
+ if (!resolved)
297
+ return { ranges: nativeRanges, elements: [] };
298
+ const ranges = [];
299
+ const elements = [];
300
+ for (const target of resolved) {
301
+ if (typeof Range !== "undefined" && target instanceof Range) {
302
+ if (!this.isRangeWithinScope(target, scope)) {
303
+ return { ranges: nativeRanges, elements: [] };
304
+ }
305
+ ranges.push(target);
306
+ continue;
307
+ }
308
+ if (typeof Element !== "undefined" && target instanceof Element) {
309
+ if (!this.isElementWithinScope(target, scope)) {
310
+ return { ranges: nativeRanges, elements: [] };
311
+ }
312
+ elements.push(target);
313
+ continue;
314
+ }
315
+ return { ranges: nativeRanges, elements: [] };
316
+ }
317
+ return { ranges, elements };
318
+ }
319
+ catch {
320
+ // Fail open: custom resolver failures must not interrupt TTS playback.
321
+ return { ranges: nativeRanges, elements: [] };
322
+ }
323
+ }
324
+ createTextRange(node, startOffset, endOffset) {
325
+ if (typeof document === "undefined")
326
+ return null;
327
+ if (typeof document.createRange !== "function")
328
+ return null;
329
+ try {
330
+ const range = document.createRange();
331
+ range.setStart(node, startOffset);
332
+ range.setEnd(node, endOffset);
333
+ if (range.startContainer !== node || range.endContainer !== node) {
334
+ return null;
335
+ }
336
+ return range;
337
+ }
338
+ catch {
339
+ return null;
340
+ }
341
+ }
342
+ paintTTSWordRange(range) {
343
+ if (!this.highlightCoordinator)
344
+ return;
345
+ try {
346
+ if (range.startContainer === range.endContainer &&
347
+ range.startContainer.nodeType === Node.TEXT_NODE) {
348
+ this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
349
+ return;
350
+ }
351
+ }
352
+ catch {
353
+ // Fall through to generic range painting for lightweight test doubles.
354
+ }
355
+ this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
356
+ }
357
+ paintTTSSentenceRanges(nativeRanges) {
358
+ if (!this.highlightCoordinator)
359
+ return;
360
+ const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
361
+ if (elements.length > 0 &&
362
+ ranges.length === 0 &&
363
+ this.highlightCoordinator.highlightTTSSentenceElements) {
364
+ this.highlightCoordinator.highlightTTSSentenceElements(elements);
365
+ return;
366
+ }
367
+ const paintedRanges = [...ranges];
368
+ for (const element of elements) {
369
+ const elementRange = document.createRange();
370
+ elementRange.selectNodeContents(element);
371
+ paintedRanges.push(elementRange);
372
+ }
373
+ this.highlightCoordinator.highlightTTSSentence(paintedRanges);
374
+ }
223
375
  /**
224
376
  * Get provider capabilities
225
377
  */
@@ -577,11 +729,11 @@ export class TTSService {
577
729
  this.currentBoundaryOffset = 0;
578
730
  const originalOnWordBoundary = this.provider.onWordBoundary;
579
731
  this.provider.onWordBoundary = (word, position, length) => {
732
+ originalOnWordBoundary?.(word, position, length);
580
733
  if (Number.isFinite(position)) {
581
734
  this.currentBoundaryOffset = position;
582
735
  this.currentSeekSegmentIndex = this.getCurrentSeekSegmentIndex();
583
736
  }
584
- originalOnWordBoundary?.(word, position, length);
585
737
  };
586
738
  try {
587
739
  await providerWithPlan.speakSegments(segments);
@@ -593,6 +745,10 @@ export class TTSService {
593
745
  }
594
746
  return;
595
747
  }
748
+ this.configureWordBoundaryHighlighting({
749
+ highlightMode: options?.highlightMode || "word",
750
+ wordBoundaryOffset: this.activeWordBoundaryOffset,
751
+ });
596
752
  for (const segment of segments) {
597
753
  if (runId !== this.speakRunId)
598
754
  return;
@@ -624,7 +780,7 @@ export class TTSService {
624
780
  const range = document.createRange();
625
781
  range.setStart(start.node, start.offset);
626
782
  range.setEnd(end.node, end.offset + 1);
627
- this.highlightCoordinator.highlightTTSSentence([range]);
783
+ this.paintTTSSentenceRanges([range]);
628
784
  this.activeSentenceStartOffset = startOffset;
629
785
  }
630
786
  getSegmentIndexForOffset(segments, offset) {
@@ -732,6 +888,7 @@ export class TTSService {
732
888
  const highlightMode = options?.highlightModeOverride ||
733
889
  (speechMatchesVisibleText ? this.resolveHighlightMode() : "sentence");
734
890
  this.activeHighlightMode = highlightMode;
891
+ this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
735
892
  const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
736
893
  const shouldUsePlan = !!this.currentContentElement &&
737
894
  !usedCatalogSpoken &&
@@ -1168,7 +1325,7 @@ export class TTSService {
1168
1325
  try {
1169
1326
  const range = document.createRange();
1170
1327
  range.selectNodeContents(this.currentContentElement);
1171
- this.highlightCoordinator.highlightTTSSentence([range]);
1328
+ this.paintTTSSentenceRanges([range]);
1172
1329
  }
1173
1330
  catch {
1174
1331
  // No-op fallback when DOM range creation fails.
@@ -1192,7 +1349,13 @@ export class TTSService {
1192
1349
  if (highlightRange && this.highlightCoordinator) {
1193
1350
  const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
1194
1351
  this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
1195
- this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1352
+ const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
1353
+ if (nativeRange) {
1354
+ this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1355
+ }
1356
+ else {
1357
+ this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1358
+ }
1196
1359
  }
1197
1360
  else {
1198
1361
  console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
@@ -1212,7 +1375,7 @@ export class TTSService {
1212
1375
  this.lastRenderedRegionTarget = null;
1213
1376
  if (chunk.regionRange) {
1214
1377
  this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1215
- this.highlightCoordinator.highlightTTSSentence([chunk.regionRange]);
1378
+ this.paintTTSSentenceRanges([chunk.regionRange]);
1216
1379
  return;
1217
1380
  }
1218
1381
  if (!element)
@@ -1220,7 +1383,7 @@ export class TTSService {
1220
1383
  const range = document.createRange();
1221
1384
  range.selectNodeContents(element);
1222
1385
  this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1223
- this.highlightCoordinator.highlightTTSSentence([range]);
1386
+ this.paintTTSSentenceRanges([range]);
1224
1387
  }
1225
1388
  catch {
1226
1389
  // Continue playback even when a detached node cannot be highlighted.
@@ -1229,12 +1392,7 @@ export class TTSService {
1229
1392
  highlightCatalogActiveRange(range) {
1230
1393
  if (!this.highlightCoordinator)
1231
1394
  return;
1232
- if (range.startContainer === range.endContainer &&
1233
- range.startContainer.nodeType === Node.TEXT_NODE) {
1234
- this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
1235
- return;
1236
- }
1237
- this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
1395
+ this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
1238
1396
  }
1239
1397
  highlightRenderableRegionTarget(target) {
1240
1398
  if (!this.highlightCoordinator ||
@@ -1242,7 +1400,8 @@ export class TTSService {
1242
1400
  typeof document === "undefined") {
1243
1401
  return;
1244
1402
  }
1245
- if (sameRenderableHighlightTarget(this.lastRenderedRegionTarget, target)) {
1403
+ if (!this.highlightTargetResolverProvider &&
1404
+ sameRenderableHighlightTarget(this.lastRenderedRegionTarget, target)) {
1246
1405
  return;
1247
1406
  }
1248
1407
  try {
@@ -1251,7 +1410,7 @@ export class TTSService {
1251
1410
  range.selectNodeContents(target.element);
1252
1411
  }
1253
1412
  else if (target.type === "range") {
1254
- this.highlightCoordinator.highlightTTSSentence([target.range]);
1413
+ this.paintTTSSentenceRanges([target.range]);
1255
1414
  this.lastRenderedRegionTarget = target;
1256
1415
  return;
1257
1416
  }
@@ -1259,7 +1418,7 @@ export class TTSService {
1259
1418
  range.setStart(target.node, target.startOffset);
1260
1419
  range.setEnd(target.node, target.endOffset);
1261
1420
  }
1262
- this.highlightCoordinator.highlightTTSSentence([range]);
1421
+ this.paintTTSSentenceRanges([range]);
1263
1422
  this.lastRenderedRegionTarget = target;
1264
1423
  }
1265
1424
  catch {
@@ -1280,13 +1439,26 @@ export class TTSService {
1280
1439
  return;
1281
1440
  }
1282
1441
  if (target.type === "text-range") {
1283
- this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1442
+ const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
1443
+ if (nativeRange) {
1444
+ this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1445
+ }
1446
+ else {
1447
+ this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1448
+ }
1284
1449
  return;
1285
1450
  }
1286
1451
  if (target.type === "range") {
1287
1452
  this.highlightCatalogActiveRange(target.range);
1288
1453
  return;
1289
1454
  }
1455
+ const elementNativeRange = document.createRange();
1456
+ elementNativeRange.selectNodeContents(target.element);
1457
+ const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
1458
+ if (resolvedElementRange.remapped) {
1459
+ this.paintTTSWordRange(resolvedElementRange.range);
1460
+ return;
1461
+ }
1290
1462
  // Element targets are atomic: a resolved math token, a whole-expression
1291
1463
  // fallback, or a replaced element (image/svg). Paint exactly the resolved
1292
1464
  // element. Native MathML / HTML tokens that expose a single text node keep
@@ -1438,6 +1610,7 @@ export class TTSService {
1438
1610
  this.currentContentElement = null;
1439
1611
  this.normalizedToDOM.clear();
1440
1612
  this.currentBoundaryOffset = 0;
1613
+ this.activeWordBoundaryOffset = 0;
1441
1614
  this.seekSegments = [];
1442
1615
  this.sentenceHighlightSegments = [];
1443
1616
  this.currentSeekSegmentIndex = 0;
@@ -1514,32 +1687,27 @@ export class TTSService {
1514
1687
  this.setState(PlaybackState.PLAYING);
1515
1688
  }
1516
1689
  }
1517
- async seekBy(units) {
1518
- if (!this.provider || !this.currentText)
1519
- return;
1520
- if (this.state !== PlaybackState.PLAYING &&
1521
- this.state !== PlaybackState.PAUSED) {
1522
- return;
1523
- }
1524
- if (this.hasExplicitBreakSemantics(this.currentText))
1525
- return;
1526
- if (this.seekSegments.length === 0)
1527
- return;
1528
- const delta = Number.isFinite(units) ? Math.trunc(units) : 0;
1529
- if (delta === 0)
1530
- return;
1531
- const currentIndex = this.getCurrentSeekSegmentIndex();
1532
- const targetIndex = Math.max(0, Math.min(this.seekSegments.length - 1, currentIndex + delta));
1533
- if (targetIndex === currentIndex)
1690
+ normalizePlaybackRate(rate) {
1691
+ if (!Number.isFinite(rate))
1692
+ return 1;
1693
+ return Math.max(0.25, Math.min(4, rate));
1694
+ }
1695
+ async restartFromSeekIndex(targetIndex) {
1696
+ if (!this.provider || this.seekSegments.length === 0)
1534
1697
  return;
1698
+ const safeTargetIndex = Math.max(0, Math.min(this.seekSegments.length - 1, targetIndex));
1535
1699
  this.speakRunId += 1;
1536
1700
  this.provider.onWordBoundary = undefined;
1537
1701
  this.provider.stop();
1538
- this.currentSeekSegmentIndex = targetIndex;
1702
+ this.currentSeekSegmentIndex = safeTargetIndex;
1539
1703
  const runId = ++this.speakRunId;
1540
- const restartSegments = this.seekSegments.slice(targetIndex);
1704
+ const restartSegments = this.seekSegments.slice(safeTargetIndex);
1541
1705
  this.setState(PlaybackState.PLAYING);
1542
1706
  try {
1707
+ this.configureWordBoundaryHighlighting({
1708
+ highlightMode: this.activeHighlightMode,
1709
+ wordBoundaryOffset: this.activeWordBoundaryOffset,
1710
+ });
1543
1711
  await this.speakWithPlan(restartSegments, runId, {
1544
1712
  highlightMode: this.activeHighlightMode,
1545
1713
  });
@@ -1547,6 +1715,7 @@ export class TTSService {
1547
1715
  return;
1548
1716
  this.setState(PlaybackState.IDLE);
1549
1717
  this.clearHighlightsAndTracking();
1718
+ this.highlightTargetResolverProvider = null;
1550
1719
  }
1551
1720
  catch (error) {
1552
1721
  if (runId !== this.speakRunId)
@@ -1554,9 +1723,41 @@ export class TTSService {
1554
1723
  this.lastError = error instanceof Error ? error.message : String(error);
1555
1724
  this.setState(PlaybackState.ERROR);
1556
1725
  this.clearHighlightsAndTracking();
1726
+ this.highlightTargetResolverProvider = null;
1557
1727
  throw error;
1558
1728
  }
1559
1729
  }
1730
+ async setPlaybackRate(rate) {
1731
+ const nextRate = this.normalizePlaybackRate(rate);
1732
+ await this.updateSettings({ rate: nextRate });
1733
+ if (this.state !== PlaybackState.PLAYING ||
1734
+ !this.currentText ||
1735
+ this.seekSegments.length === 0 ||
1736
+ this.hasExplicitBreakSemantics(this.currentText)) {
1737
+ return;
1738
+ }
1739
+ await this.restartFromSeekIndex(this.getCurrentSeekSegmentIndex());
1740
+ }
1741
+ async seekBy(units) {
1742
+ if (!this.provider || !this.currentText)
1743
+ return;
1744
+ if (this.state !== PlaybackState.PLAYING &&
1745
+ this.state !== PlaybackState.PAUSED) {
1746
+ return;
1747
+ }
1748
+ if (this.hasExplicitBreakSemantics(this.currentText))
1749
+ return;
1750
+ if (this.seekSegments.length === 0)
1751
+ return;
1752
+ const delta = Number.isFinite(units) ? Math.trunc(units) : 0;
1753
+ if (delta === 0)
1754
+ return;
1755
+ const currentIndex = this.getCurrentSeekSegmentIndex();
1756
+ const targetIndex = Math.max(0, Math.min(this.seekSegments.length - 1, currentIndex + delta));
1757
+ if (targetIndex === currentIndex)
1758
+ return;
1759
+ await this.restartFromSeekIndex(targetIndex);
1760
+ }
1560
1761
  async seekForward(units = 1) {
1561
1762
  const step = Number.isFinite(units) ? Math.max(1, Math.trunc(units)) : 1;
1562
1763
  await this.seekBy(step);
@@ -1574,6 +1775,7 @@ export class TTSService {
1574
1775
  this.speakRunId += 1;
1575
1776
  this.provider.onWordBoundary = undefined;
1576
1777
  this.provider.stop();
1778
+ this.highlightTargetResolverProvider = null;
1577
1779
  this.setState(PlaybackState.IDLE);
1578
1780
  this.currentText = null;
1579
1781
  // Clear highlights
@@ -1585,6 +1787,7 @@ export class TTSService {
1585
1787
  this.currentContentElement = null;
1586
1788
  this.normalizedToDOM.clear();
1587
1789
  this.currentBoundaryOffset = 0;
1790
+ this.activeWordBoundaryOffset = 0;
1588
1791
  this.seekSegments = [];
1589
1792
  this.sentenceHighlightSegments = [];
1590
1793
  this.currentSeekSegmentIndex = 0;