@pie-players/pie-assessment-toolkit 0.3.72 → 0.3.74

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 (108) hide show
  1. package/README.md +171 -56
  2. package/dist/attempt/AssessmentSession.d.ts +1 -5
  3. package/dist/attempt/AssessmentSession.js +0 -29
  4. package/dist/components/ItemToolBar.custom-element.d.ts +1 -0
  5. package/dist/components/ItemToolBar.custom-element.js +1 -1
  6. package/dist/components/PieAssessmentToolkit.custom-element.d.ts +1 -0
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  8. package/dist/components/SectionToolBar.custom-element.d.ts +1 -0
  9. package/dist/components/SectionToolBar.custom-element.js +1 -1
  10. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +40 -0
  11. package/dist/components/chunks/ItemToolBar-gmrryv2v.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +4 -0
  13. package/dist/components/chunks/ItemToolBar-wr2myaah.js +2 -0
  14. package/dist/components/chunks/sre-engine-wf9s43gg.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +5 -0
  16. package/dist/context/runtime-context-consumer.js +5 -38
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +8 -9
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +8 -2
  20. package/dist/policy/core/ToolPolicyEngine.js +12 -0
  21. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +3 -3
  22. package/dist/runtime/adapter/SectionEngineAdapter.js +3 -3
  23. package/dist/runtime/registration-events.d.ts +3 -0
  24. package/dist/runtime/section-runtime-engine-host-context.d.ts +3 -9
  25. package/dist/runtime/section-runtime-engine-host-context.js +5 -43
  26. package/dist/runtime/tool-host-contract.d.ts +20 -0
  27. package/dist/runtime/tool-host-contract.js +21 -0
  28. package/dist/services/HighlightCoordinator.d.ts +1 -7
  29. package/dist/services/HighlightCoordinator.js +1 -3
  30. package/dist/services/TTSService.d.ts +15 -1
  31. package/dist/services/TTSService.js +88 -35
  32. package/dist/services/ToolCoordinator.d.ts +1 -7
  33. package/dist/services/ToolCoordinator.js +1 -3
  34. package/dist/services/ToolRegistry.d.ts +13 -6
  35. package/dist/services/ToolRegistry.js +19 -7
  36. package/dist/services/ToolkitCoordinator.d.ts +110 -18
  37. package/dist/services/ToolkitCoordinator.js +273 -85
  38. package/dist/services/catalog-owner.d.ts +11 -0
  39. package/dist/services/catalog-owner.js +25 -0
  40. package/dist/services/create-tools-config.d.ts +2 -2
  41. package/dist/services/framework-error.d.ts +8 -1
  42. package/dist/services/interfaces.d.ts +9 -9
  43. package/dist/services/pnp-standard-features.d.ts +1 -1
  44. package/dist/services/section-controller-types.d.ts +30 -0
  45. package/dist/services/spoken-audio-cards.js +1 -1
  46. package/dist/services/tool-config-validation.d.ts +13 -2
  47. package/dist/services/tool-config-validation.js +33 -16
  48. package/dist/services/tool-context.js +29 -13
  49. package/dist/services/tool-providers/TTSToolProvider.d.ts +27 -65
  50. package/dist/services/tool-providers/TTSToolProvider.js +47 -9
  51. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +4 -4
  52. package/dist/services/tool-providers/ToolProviderRegistry.js +4 -16
  53. package/dist/services/tool-providers/index.d.ts +0 -9
  54. package/dist/services/tool-providers/index.js +2 -4
  55. package/dist/services/tools-config-normalizer.d.ts +20 -1
  56. package/dist/services/tts/catalog-span-alignment.d.ts +2 -17
  57. package/dist/services/tts/catalog-span-alignment.js +17 -142
  58. package/dist/services/tts/generated-speech/types.d.ts +0 -12
  59. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +12 -62
  60. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +26 -1
  61. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +3 -6
  62. package/dist/services/tts/math-alignment/index.d.ts +0 -5
  63. package/dist/services/tts/math-alignment/index.js +5 -25
  64. package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +9 -1
  65. package/dist/services/tts/math-alignment/speech-tokenizer.js +38 -36
  66. package/dist/services/tts/math-alignment/types.d.ts +4 -2
  67. package/dist/services/tts/math-aware-text-processing.js +44 -2
  68. package/dist/services/tts/math-speech.d.ts +5 -1
  69. package/dist/services/tts/math-speech.js +9 -6
  70. package/dist/services/tts/sre-engine.d.ts +7 -0
  71. package/dist/services/tts/sre-engine.js +11 -0
  72. package/dist/services/tts/sre-locales.d.ts +14 -0
  73. package/dist/services/tts/sre-locales.js +57 -0
  74. package/dist/services/tts/text-processing.d.ts +0 -1
  75. package/dist/services/tts/text-processing.js +0 -1
  76. package/dist/services/tts-runtime-config.d.ts +21 -3
  77. package/dist/services/tts-runtime-config.js +11 -3
  78. package/dist/tools/client.d.ts +1 -3
  79. package/dist/tools/client.js +1 -5
  80. package/dist/tools/internal.d.ts +6 -5
  81. package/dist/tools/internal.js +6 -8
  82. package/package.json +17 -36
  83. package/dist/components/chunks/ItemToolBar-byq80z42.js +0 -40
  84. package/dist/components/chunks/ItemToolBar-d36g3aw4.js +0 -49
  85. package/dist/services/ToolConfigResolver.d.ts +0 -161
  86. package/dist/services/ToolConfigResolver.js +0 -229
  87. package/dist/services/catalog-media.d.ts +0 -56
  88. package/dist/services/catalog-media.js +0 -190
  89. package/dist/services/createDefaultToolRegistry.d.ts +0 -53
  90. package/dist/services/createDefaultToolRegistry.js +0 -45
  91. package/dist/services/tool-providers/CortexToolProvider.d.ts +0 -13
  92. package/dist/services/tool-providers/CortexToolProvider.js +0 -32
  93. package/dist/services/tool-providers/DesmosToolProvider.d.ts +0 -14
  94. package/dist/services/tool-providers/DesmosToolProvider.js +0 -32
  95. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +0 -16
  96. package/dist/services/tool-providers/GeoGebraToolProvider.js +0 -32
  97. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +0 -35
  98. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +0 -95
  99. package/dist/services/tts/visual-line-ranges.d.ts +0 -18
  100. package/dist/services/tts/visual-line-ranges.js +0 -226
  101. package/dist/tools/index.d.ts +0 -8
  102. package/dist/tools/index.js +0 -10
  103. package/dist/tools/response-discovery.d.ts +0 -61
  104. package/dist/tools/response-discovery.js +0 -113
  105. package/dist/tools/types.d.ts +0 -337
  106. package/dist/tools/types.js +0 -12
  107. package/dist/tools/variant-resolver.d.ts +0 -41
  108. package/dist/tools/variant-resolver.js +0 -207
@@ -39,9 +39,9 @@
39
39
  * `scripts/check-engine-core-purity.mjs` enforce the core's purity;
40
40
  * the adapter follows the same constraint by convention so it stays
41
41
  * usable from non-Svelte hosts (Node tests, Storybook, or future
42
- * non-Svelte consumers). `bun run check:custom-elements` already
43
- * blocks `.svelte` imports in published `dist`; adding a Svelte
44
- * dependency here would also leak through that gate.
42
+ * non-Svelte consumers). `bun run check:custom-elements:dist`
43
+ * already blocks `.svelte` imports in published `dist`; adding a
44
+ * Svelte dependency here would also leak through that gate.
45
45
  */
46
46
  import type { FrameworkErrorReporter } from "../../services/framework-error-bus.js";
47
47
  import { SectionEngineCore } from "../core/SectionEngineCore.js";
@@ -39,9 +39,9 @@
39
39
  * `scripts/check-engine-core-purity.mjs` enforce the core's purity;
40
40
  * the adapter follows the same constraint by convention so it stays
41
41
  * usable from non-Svelte hosts (Node tests, Storybook, or future
42
- * non-Svelte consumers). `bun run check:custom-elements` already
43
- * blocks `.svelte` imports in published `dist`; adding a Svelte
44
- * dependency here would also leak through that gate.
42
+ * non-Svelte consumers). `bun run check:custom-elements:dist`
43
+ * already blocks `.svelte` imports in published `dist`; adding a
44
+ * Svelte dependency here would also leak through that gate.
45
45
  */
46
46
  import { SectionEngineCore } from "../core/SectionEngineCore.js";
47
47
  import { createCoordinatorBridge, } from "./coordinator-bridge.js";
@@ -1,3 +1,4 @@
1
+ import type { SessionCommitReason } from "@pie-players/pie-players-shared/pie";
1
2
  import type { MediaTimeSource } from "@pie-players/pie-players-shared/timed-media";
2
3
  export declare const PIE_REGISTER_EVENT = "pie-register";
3
4
  export declare const PIE_UNREGISTER_EVENT = "pie-unregister";
@@ -21,6 +22,8 @@ export interface ItemSessionChangedDetail {
21
22
  canonicalItemId?: string;
22
23
  session: unknown;
23
24
  sourceRuntimeId?: string;
25
+ /** Set when the change is a commit at a teardown, navigation or page-hidden seam. */
26
+ sessionCommitReason?: SessionCommitReason;
24
27
  }
25
28
  export interface InternalItemSessionChangedDetail {
26
29
  itemId: string;
@@ -62,15 +62,9 @@ export declare const sectionRuntimeEngineHostContext: {
62
62
  };
63
63
  export type SectionRuntimeEngineHostContextListener = (value: SectionRuntimeEngineHostContextValue) => void;
64
64
  /**
65
- * Connect a DOM host to the cross-CE engine context with the same
66
- * provider-retry semantics used elsewhere in the toolkit (initial
67
- * synchronous request + `context-provider` re-request + 50 ms polling
68
- * fallback up to 200 attempts ≈ 10 s).
69
- *
70
- * The retry mirrors the existing
71
- * `connectAssessmentToolkitHostRuntimeContext` infrastructure so the
72
- * timing characteristics of cross-CE handshakes stay consistent across
73
- * the toolkit's contexts.
65
+ * Connect a DOM host to the cross-CE engine context through
66
+ * `connectContextWithRetry`, the provider-retry the toolkit's other
67
+ * contexts use.
74
68
  *
75
69
  * Returns a cleanup function that disconnects the consumer and clears
76
70
  * the retry interval. Standalone toolkits naturally exhaust the retry
@@ -35,7 +35,7 @@
35
35
  * breaking change. Adding optional fields to the value shape is
36
36
  * allowed; required fields are not.
37
37
  */
38
- import { ContextConsumer, createContext, } from "@pie-players/pie-context";
38
+ import { connectContextWithRetry, createContext, } from "@pie-players/pie-context";
39
39
  /**
40
40
  * `pie-context` context key for the cross-CE engine bridge. Uses a
41
41
  * `Symbol.for(...)` registry key so providers and consumers in
@@ -43,15 +43,9 @@ import { ContextConsumer, createContext, } from "@pie-players/pie-context";
43
43
  */
44
44
  export const sectionRuntimeEngineHostContext = createContext(Symbol.for("pie.sectionRuntimeEngineHostContext"));
45
45
  /**
46
- * Connect a DOM host to the cross-CE engine context with the same
47
- * provider-retry semantics used elsewhere in the toolkit (initial
48
- * synchronous request + `context-provider` re-request + 50 ms polling
49
- * fallback up to 200 attempts ≈ 10 s).
50
- *
51
- * The retry mirrors the existing
52
- * `connectAssessmentToolkitHostRuntimeContext` infrastructure so the
53
- * timing characteristics of cross-CE handshakes stay consistent across
54
- * the toolkit's contexts.
46
+ * Connect a DOM host to the cross-CE engine context through
47
+ * `connectContextWithRetry`, the provider-retry the toolkit's other
48
+ * contexts use.
55
49
  *
56
50
  * Returns a cleanup function that disconnects the consumer and clears
57
51
  * the retry interval. Standalone toolkits naturally exhaust the retry
@@ -59,37 +53,5 @@ export const sectionRuntimeEngineHostContext = createContext(Symbol.for("pie.sec
59
53
  * engine — that is by design and is not an error.
60
54
  */
61
55
  export function connectSectionRuntimeEngineHostContext(host, onValue) {
62
- let hasValue = false;
63
- const consumer = new ContextConsumer(host, {
64
- context: sectionRuntimeEngineHostContext,
65
- subscribe: true,
66
- onValue: (value) => {
67
- hasValue = true;
68
- onValue(value);
69
- },
70
- });
71
- consumer.connect();
72
- const onContextProvider = (event) => {
73
- if (event.context !==
74
- sectionRuntimeEngineHostContext) {
75
- return;
76
- }
77
- consumer.requestValue();
78
- };
79
- host.addEventListener("context-provider", onContextProvider);
80
- let attempts = 0;
81
- const maxAttempts = 200;
82
- const retryTimer = globalThis.setInterval(() => {
83
- if (hasValue || attempts >= maxAttempts) {
84
- globalThis.clearInterval(retryTimer);
85
- return;
86
- }
87
- attempts += 1;
88
- consumer.requestValue();
89
- }, 50);
90
- return () => {
91
- globalThis.clearInterval(retryTimer);
92
- host.removeEventListener("context-provider", onContextProvider);
93
- consumer.disconnect();
94
- };
56
+ return connectContextWithRetry(host, sectionRuntimeEngineHostContext, onValue);
95
57
  }
@@ -1,5 +1,7 @@
1
1
  import type { UnknownContext } from "@pie-players/pie-context";
2
2
  import { type RegionScopeContextListener, type RuntimeContextListener, type ShellContextListener } from "../context/runtime-context-consumer.js";
3
+ import type { ZIndexLayer } from "../services/ToolCoordinator.js";
4
+ import type { ToolCoordinatorApi } from "../services/interfaces.js";
3
5
  type BaseEventInit = Pick<EventInit, "bubbles" | "composed" | "cancelable">;
4
6
  /**
5
7
  * Contract helper for all toolkit/tool events that must cross custom-element boundaries.
@@ -22,6 +24,24 @@ export declare function connectToolShellContext(host: HTMLElement, onValue: Shel
22
24
  * Shared region-scope context contract for tools targeting content subregions.
23
25
  */
24
26
  export declare function connectToolRegionScopeContext(host: HTMLElement, onValue: RegionScopeContextListener): () => void;
27
+ /** A tool's registration with the coordinator in its runtime context. */
28
+ export interface ToolCoordinatorRegistration {
29
+ /**
30
+ * Registers `toolId` with `coordinator`, first unregistering it from the
31
+ * coordinator or id it was registered under. The coordinator arrives through
32
+ * a republished runtime context, so a new instance can replace the old one
33
+ * mid-session; a one-shot registration would leave z-index, `bringToFront`
34
+ * and visibility-restore bound to the dead one. Call it from an effect on
35
+ * both; it does nothing until both are set, or when neither changed.
36
+ */
37
+ sync(coordinator: ToolCoordinatorApi | null | undefined, toolId: string | null | undefined): void;
38
+ /**
39
+ * Unregisters from the coordinator the registration was made against, which
40
+ * is not necessarily the one currently in context.
41
+ */
42
+ release(): void;
43
+ }
44
+ export declare function createToolCoordinatorRegistration(name: string, layer: ZIndexLayer): ToolCoordinatorRegistration;
25
45
  /**
26
46
  * Guard utility: narrows unknown context payloads when needed by callers.
27
47
  */
@@ -39,6 +39,27 @@ export function connectToolShellContext(host, onValue) {
39
39
  export function connectToolRegionScopeContext(host, onValue) {
40
40
  return connectAssessmentToolkitRegionScopeContext(host, onValue);
41
41
  }
42
+ export function createToolCoordinatorRegistration(name, layer) {
43
+ let registered = null;
44
+ function release() {
45
+ registered?.coordinator.unregisterTool(registered.toolId);
46
+ registered = null;
47
+ }
48
+ return {
49
+ sync(coordinator, toolId) {
50
+ if (!coordinator || !toolId)
51
+ return;
52
+ if (registered?.coordinator === coordinator &&
53
+ registered.toolId === toolId) {
54
+ return;
55
+ }
56
+ release();
57
+ coordinator.registerTool(toolId, name, undefined, layer);
58
+ registered = { coordinator, toolId };
59
+ },
60
+ release,
61
+ };
62
+ }
42
63
  /**
43
64
  * Guard utility: narrows unknown context payloads when needed by callers.
44
65
  */
@@ -51,13 +51,7 @@ export interface Annotation {
51
51
  color: HighlightColor;
52
52
  timestamp: number;
53
53
  }
54
- /**
55
- * Configuration for HighlightCoordinator
56
- * Currently empty but allows future extension without breaking changes
57
- */
58
- export type HighlightCoordinatorConfig = Record<string, never>;
59
54
  export declare class HighlightCoordinator implements HighlightCoordinatorApi {
60
- private config;
61
55
  private ttsWordHighlight;
62
56
  private ttsSentenceHighlight;
63
57
  private ttsWordElementHighlights;
@@ -69,7 +63,7 @@ export declare class HighlightCoordinator implements HighlightCoordinatorApi {
69
63
  private rangeSerializer;
70
64
  private themeObserver;
71
65
  private explicitTTSColorOverride;
72
- constructor(config?: HighlightCoordinatorConfig);
66
+ constructor();
73
67
  private setupThemeObservation;
74
68
  private parseColor;
75
69
  private relativeLuminance;
@@ -43,7 +43,6 @@ export var HighlightColor;
43
43
  HighlightColor["UNDERLINE"] = "underline";
44
44
  })(HighlightColor || (HighlightColor = {}));
45
45
  export class HighlightCoordinator {
46
- config;
47
46
  ttsWordHighlight = null;
48
47
  ttsSentenceHighlight = null;
49
48
  ttsWordElementHighlights = new Set();
@@ -56,8 +55,7 @@ export class HighlightCoordinator {
56
55
  rangeSerializer;
57
56
  themeObserver = null;
58
57
  explicitTTSColorOverride = null;
59
- constructor(config = {}) {
60
- this.config = config;
58
+ constructor() {
61
59
  this.rangeSerializer = new RangeSerializer();
62
60
  // SSR guard
63
61
  if (typeof CSS === "undefined" || !("highlights" in CSS)) {
@@ -66,7 +66,6 @@ export declare class TTSService {
66
66
  private playbackChunks;
67
67
  private activeRecordedAudio;
68
68
  private sentenceHighlightSegments;
69
- private currentSeekSegmentIndex;
70
69
  private activeSentenceStartOffset;
71
70
  private playbackStartDeferredRunId;
72
71
  private pendingPlaybackStartHighlights;
@@ -171,6 +170,11 @@ export declare class TTSService {
171
170
  private isElementHidden;
172
171
  private getBoundaryStrength;
173
172
  private getBoundaryAnchor;
173
+ /**
174
+ * `textOffset` is where `normalizedText` starts in the element's visible text:
175
+ * boundaries are collected over the whole element, and a selection speaks only
176
+ * part of it.
177
+ */
174
178
  private createSpeechPlan;
175
179
  private collectSpeechPlanBoundaries;
176
180
  private createNodeStartOffsets;
@@ -182,6 +186,11 @@ export declare class TTSService {
182
186
  private createSeekSegmentsFromSpeechChunks;
183
187
  private getCurrentSeekSegmentIndex;
184
188
  private speakWithPlan;
189
+ /**
190
+ * `startOffset` indexes the spoken text; the position map indexes the whole
191
+ * content element, which for a selection starts `activeWordBoundaryOffset`
192
+ * characters earlier.
193
+ */
185
194
  private highlightSentenceSegment;
186
195
  private getSegmentIndexForOffset;
187
196
  private highlightSentenceForOffset;
@@ -252,6 +261,11 @@ export declare class TTSService {
252
261
  *
253
262
  * @param range DOM Range to speak
254
263
  */
264
+ /**
265
+ * The selected part of `root`'s normalized visible text and where it starts, or
266
+ * null when the map is unavailable or holds no selected character.
267
+ */
268
+ private selectMappedRangeText;
255
269
  speakRange(range: Range, options?: {
256
270
  contentRoot?: Element | null;
257
271
  }): Promise<void>;
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
- import { applyMediaFragment, enforceMediaFragment } from "./catalog-media.js";
18
+ import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
19
19
  import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
20
20
  import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
21
21
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
@@ -95,7 +95,6 @@ export class TTSService {
95
95
  // chunk loop on a run that has already been superseded.
96
96
  activeRecordedAudio = null;
97
97
  sentenceHighlightSegments = [];
98
- currentSeekSegmentIndex = 0;
99
98
  activeSentenceStartOffset = null;
100
99
  playbackStartDeferredRunId = null;
101
100
  pendingPlaybackStartHighlights = [];
@@ -675,9 +674,21 @@ export class TTSService {
675
674
  units: this.getStructuralPauseProfile().units[strength],
676
675
  };
677
676
  }
678
- createSpeechPlan(contentElement, normalizedText) {
677
+ /**
678
+ * `textOffset` is where `normalizedText` starts in the element's visible text:
679
+ * boundaries are collected over the whole element, and a selection speaks only
680
+ * part of it.
681
+ */
682
+ createSpeechPlan(contentElement, normalizedText, textOffset = 0) {
679
683
  const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText);
680
- return this.createSpeechPlanSegments(normalizedText, boundaries);
684
+ if (textOffset === 0) {
685
+ return this.createSpeechPlanSegments(normalizedText, boundaries);
686
+ }
687
+ const shifted = new Map();
688
+ for (const [point, units] of boundaries) {
689
+ shifted.set(point - textOffset, units);
690
+ }
691
+ return this.createSpeechPlanSegments(normalizedText, shifted);
681
692
  }
682
693
  collectSpeechPlanBoundaries(contentElement, normalizedText) {
683
694
  const boundaries = new Map();
@@ -858,7 +869,6 @@ export class TTSService {
858
869
  originalOnWordBoundary?.(word, position, length);
859
870
  if (Number.isFinite(position)) {
860
871
  this.currentBoundaryOffset = position;
861
- this.currentSeekSegmentIndex = this.getCurrentSeekSegmentIndex();
862
872
  }
863
873
  };
864
874
  try {
@@ -878,10 +888,6 @@ export class TTSService {
878
888
  for (const segment of segments) {
879
889
  if (runId !== this.speakRunId)
880
890
  return;
881
- const seekIndex = this.seekSegments.findIndex((candidate) => candidate.startOffset === segment.startOffset);
882
- if (seekIndex >= 0) {
883
- this.currentSeekSegmentIndex = seekIndex;
884
- }
885
891
  this.currentBoundaryOffset = segment.startOffset;
886
892
  if (shouldTrackSentenceProgress) {
887
893
  this.runWhenPlaybackStarts(runId, () => {
@@ -895,14 +901,20 @@ export class TTSService {
895
901
  }
896
902
  }
897
903
  }
904
+ /**
905
+ * `startOffset` indexes the spoken text; the position map indexes the whole
906
+ * content element, which for a selection starts `activeWordBoundaryOffset`
907
+ * characters earlier.
908
+ */
898
909
  highlightSentenceSegment(startOffset, text) {
899
910
  if (!this.highlightCoordinator || typeof document === "undefined")
900
911
  return;
901
912
  const length = text.trimEnd().length;
902
913
  if (length <= 0)
903
914
  return;
904
- const start = this.normalizedToDOM.get(startOffset);
905
- const end = this.normalizedToDOM.get(startOffset + length - 1);
915
+ const mapStart = startOffset + this.activeWordBoundaryOffset;
916
+ const start = this.normalizedToDOM.get(mapStart);
917
+ const end = this.normalizedToDOM.get(mapStart + length - 1);
906
918
  if (!start || !end)
907
919
  return;
908
920
  const range = document.createRange();
@@ -1030,7 +1042,7 @@ export class TTSService {
1030
1042
  : hasExplicitBreaks || !speechMatchesVisibleText
1031
1043
  ? []
1032
1044
  : shouldUsePlan && this.currentContentElement
1033
- ? this.createSpeechPlan(this.currentContentElement, normalizedText)
1045
+ ? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0)
1034
1046
  : this.createSeekSegmentsFromText(highlightText);
1035
1047
  this.sentenceHighlightSegments = hasExplicitBreaks
1036
1048
  ? []
@@ -1520,7 +1532,6 @@ export class TTSService {
1520
1532
  this.lastError = null;
1521
1533
  this.currentBoundaryOffset = 0;
1522
1534
  this.playbackChunks = [];
1523
- this.currentSeekSegmentIndex = 0;
1524
1535
  this.activeSentenceStartOffset = null;
1525
1536
  }
1526
1537
  prepareHighlightsForSpeak(args) {
@@ -1559,8 +1570,9 @@ export class TTSService {
1559
1570
  // or ServerTTSProvider time-scaled onWordBoundary).
1560
1571
  this.provider.onWordBoundary = (word, charIndex, length) => {
1561
1572
  const wordLength = length || word.length;
1562
- const globalIndex = charIndex + this.currentBoundaryOffset + args.wordBoundaryOffset;
1563
- this.highlightSentenceForOffset(globalIndex);
1573
+ const spokenIndex = charIndex + this.currentBoundaryOffset;
1574
+ const globalIndex = spokenIndex + args.wordBoundaryOffset;
1575
+ this.highlightSentenceForOffset(spokenIndex);
1564
1576
  const highlightRange = this.findHighlightRange(globalIndex, wordLength);
1565
1577
  if (highlightRange && this.highlightCoordinator) {
1566
1578
  const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
@@ -1907,7 +1919,6 @@ export class TTSService {
1907
1919
  for (let index = 0; index < args.speechChunks.length; index++) {
1908
1920
  if (args.runId !== this.speakRunId)
1909
1921
  return;
1910
- this.currentSeekSegmentIndex = index;
1911
1922
  const segment = this.seekSegments[index];
1912
1923
  if (segment) {
1913
1924
  this.currentBoundaryOffset = segment.startOffset;
@@ -1947,7 +1958,6 @@ export class TTSService {
1947
1958
  this.seekSegments = [];
1948
1959
  this.playbackChunks = [];
1949
1960
  this.sentenceHighlightSegments = [];
1950
- this.currentSeekSegmentIndex = 0;
1951
1961
  this.activeSentenceStartOffset = null;
1952
1962
  }
1953
1963
  /**
@@ -1958,6 +1968,42 @@ export class TTSService {
1958
1968
  *
1959
1969
  * @param range DOM Range to speak
1960
1970
  */
1971
+ /**
1972
+ * The selected part of `root`'s normalized visible text and where it starts, or
1973
+ * null when the map is unavailable or holds no selected character.
1974
+ */
1975
+ selectMappedRangeText(range, root) {
1976
+ if (typeof range.comparePoint !== "function")
1977
+ return null;
1978
+ const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions());
1979
+ let start = -1;
1980
+ let end = -1;
1981
+ for (const [index, { node, offset }] of map) {
1982
+ if (index >= rootText.length)
1983
+ continue;
1984
+ let inside = false;
1985
+ try {
1986
+ inside =
1987
+ range.comparePoint(node, offset) === 0 &&
1988
+ range.comparePoint(node, Math.min(offset + 1, node.length)) === 0;
1989
+ }
1990
+ catch {
1991
+ inside = false;
1992
+ }
1993
+ if (!inside)
1994
+ continue;
1995
+ if (start === -1 || index < start)
1996
+ start = index;
1997
+ if (index > end)
1998
+ end = index;
1999
+ }
2000
+ if (start === -1)
2001
+ return null;
2002
+ const raw = rootText.slice(start, end + 1);
2003
+ const leading = raw.length - raw.trimStart().length;
2004
+ const text = raw.trim();
2005
+ return text ? { text, offset: start + leading } : null;
2006
+ }
1961
2007
  async speakRange(range, options) {
1962
2008
  if (!this.provider) {
1963
2009
  throw new Error("TTS service not initialized");
@@ -1983,28 +2029,38 @@ export class TTSService {
1983
2029
  if (!root)
1984
2030
  return;
1985
2031
  const selected = collectRangeTextForSpeech(range, root);
1986
- const text = selected.text.trim();
1987
- if (!text) {
2032
+ const selectedText = selected.text.trim();
2033
+ if (!selectedText) {
1988
2034
  if (selected.filtered) {
1989
2035
  console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
1990
2036
  }
1991
2037
  return;
1992
2038
  }
1993
- // Calculate the offset of the range start within the root element. Filtered
1994
- // the same way as the speech itself: the offset indexes into the highlight
1995
- // text, which comes from the exclusion-aware collectors, so counting
1996
- // characters here that never reach that text would shift every highlight
1997
- // after the excluded node.
1998
- const beforeRange = document.createRange();
1999
- beforeRange.selectNodeContents(root);
2000
- beforeRange.setEnd(range.startContainer, range.startOffset);
2001
- const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
2002
- const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
2003
- const offset = normalizedTextBeforeRange.length +
2004
- (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
2039
+ // The spoken text and its offset come from the root's visible-text map, which
2040
+ // the highlight position map and the structural speech plan index too. Joined
2041
+ // from raw text nodes instead, a selection loses the space the map inserts
2042
+ // between blocks, and every offset after that boundary drifts.
2043
+ const mapped = this.selectMappedRangeText(range, root);
2044
+ let text = selectedText;
2045
+ let offset = mapped?.offset ?? 0;
2046
+ if (mapped) {
2047
+ text = mapped.text;
2048
+ }
2049
+ else {
2050
+ // Filtered the same way as the speech itself: the offset indexes into the
2051
+ // highlight text, which comes from the exclusion-aware collectors.
2052
+ const beforeRange = document.createRange();
2053
+ beforeRange.selectNodeContents(root);
2054
+ beforeRange.setEnd(range.startContainer, range.startOffset);
2055
+ const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
2056
+ const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
2057
+ offset =
2058
+ normalizedTextBeforeRange.length +
2059
+ (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
2060
+ }
2005
2061
  this.debugLog("[TTSService] speakRange offset calculation:", {
2006
2062
  selectedText: text,
2007
- textBeforeRange: textBeforeRange.substring(0, 100),
2063
+ mapped: !!mapped,
2008
2064
  offset,
2009
2065
  rootTag: root.tagName,
2010
2066
  });
@@ -2058,7 +2114,6 @@ export class TTSService {
2058
2114
  this.cancelRecordedAudio();
2059
2115
  this.provider.onWordBoundary = undefined;
2060
2116
  this.provider.stop();
2061
- this.currentSeekSegmentIndex = safeTargetIndex;
2062
2117
  const runId = ++this.speakRunId;
2063
2118
  const restartSegments = this.seekSegments.slice(safeTargetIndex);
2064
2119
  this.highlightCoordinator?.clearTTS();
@@ -2079,7 +2134,6 @@ export class TTSService {
2079
2134
  if (runId !== this.speakRunId)
2080
2135
  return;
2081
2136
  const absoluteIndex = safeTargetIndex + index;
2082
- this.currentSeekSegmentIndex = absoluteIndex;
2083
2137
  const segment = this.seekSegments[absoluteIndex];
2084
2138
  if (segment) {
2085
2139
  this.currentBoundaryOffset = segment.startOffset;
@@ -2222,7 +2276,6 @@ export class TTSService {
2222
2276
  this.seekSegments = [];
2223
2277
  this.playbackChunks = [];
2224
2278
  this.sentenceHighlightSegments = [];
2225
- this.currentSeekSegmentIndex = 0;
2226
2279
  this.activeSentenceStartOffset = null;
2227
2280
  this.activePlaybackRate = null;
2228
2281
  }
@@ -23,13 +23,7 @@ export declare enum ZIndexLayer {
23
23
  CONTROL = 3000,// Drag handles, resize controls (3000-3999)
24
24
  HIGHLIGHT = 4000
25
25
  }
26
- /**
27
- * Configuration for ToolCoordinator
28
- * Currently empty but allows future extension without breaking changes
29
- */
30
- export type ToolCoordinatorConfig = Record<string, never>;
31
26
  export declare class ToolCoordinator implements ToolCoordinatorApi {
32
- private config;
33
27
  private tools;
34
28
  private layerCounters;
35
29
  private listeners;
@@ -42,7 +36,7 @@ export declare class ToolCoordinator implements ToolCoordinatorApi {
42
36
  * Cleared on genuine teardown via {@link releaseTool}.
43
37
  */
44
38
  private visibilityState;
45
- constructor(config?: ToolCoordinatorConfig);
39
+ constructor();
46
40
  /**
47
41
  * Subscribe to tool state changes
48
42
  * Returns unsubscribe function
@@ -26,7 +26,6 @@ export var ZIndexLayer;
26
26
  ZIndexLayer[ZIndexLayer["HIGHLIGHT"] = 4000] = "HIGHLIGHT";
27
27
  })(ZIndexLayer || (ZIndexLayer = {}));
28
28
  export class ToolCoordinator {
29
- config;
30
29
  tools = new Map();
31
30
  layerCounters = new Map();
32
31
  listeners = new Set();
@@ -39,8 +38,7 @@ export class ToolCoordinator {
39
38
  * Cleared on genuine teardown via {@link releaseTool}.
40
39
  */
41
40
  visibilityState = new Map();
42
- constructor(config = {}) {
43
- this.config = config;
41
+ constructor() {
44
42
  // Initialize layer counters
45
43
  this.layerCounters.set(ZIndexLayer.BASE, 0);
46
44
  this.layerCounters.set(ZIndexLayer.TOOL, 0);
@@ -24,8 +24,8 @@ export interface ToolToolbarButtonDefinition {
24
24
  toolId: string;
25
25
  label: string;
26
26
  /**
27
- * Optional to match what the renderers already do: `ToolButton.svelte` and
28
- * `ItemToolBar.svelte` both guard on `button.icon`, and `ToolbarItem.icon` is
27
+ * Optional to match what the renderer already does: `ItemToolBar.svelte`
28
+ * guards on `button.icon`, and `ToolbarItem.icon` is
29
29
  * already optional, so requiring it here claimed a guarantee nothing relied
30
30
  * on. A registration that renders a button still has to declare an icon —
31
31
  * `assertToolRegistrationShape` enforces that.
@@ -216,6 +216,15 @@ export interface ToolProviderDescriptor {
216
216
  getAuthFetcher?: (config: ToolRuntimeConfig | undefined) => (() => Promise<Record<string, unknown>>) | undefined;
217
217
  lazy?: boolean;
218
218
  }
219
+ /**
220
+ * The id a registration's provider registers under for one tool config: the
221
+ * descriptor's answer, then the config's `provider.id`, then the tool id. `null`
222
+ * when the registration carries no provider.
223
+ *
224
+ * The coordinator registers providers under this id and the section player looks
225
+ * for them in a host-supplied coordinator by it, so the two resolve it one way.
226
+ */
227
+ export declare function resolveToolProviderId(registration: Pick<ToolRegistration, "toolId" | "provider">, config: ToolRuntimeConfig | undefined): string | null;
219
228
  export interface ToolToolbarRenderResult {
220
229
  toolId: string;
221
230
  elements?: ToolRenderElement[];
@@ -230,12 +239,10 @@ export interface ToolToolbarRenderResult {
230
239
  * falls back to `name` rather than rendering the key, so a catalog gap degrades
231
240
  * to English instead of to `tools.something.name` on a toolbar button.
232
241
  *
233
- * Both display resolvers live here so the toolbars, the settings panels and the
234
- * PNP debugger cannot each invent their own precedence.
242
+ * It lives here so the toolbars, the settings panels and the PNP debugger
243
+ * cannot each invent their own precedence.
235
244
  */
236
245
  export declare function resolveToolRegistrationName(registration: Pick<ToolRegistration, "name" | "nameKey">, i18n?: I18nProvider): string;
237
- /** A registration's description in the interface locale. See the name resolver. */
238
- export declare function resolveToolRegistrationDescription(registration: Pick<ToolRegistration, "description" | "descriptionKey">, i18n?: I18nProvider): string;
239
246
  export type ToolActivation = "toolbar-toggle" | "selection-gateway" | "region";
240
247
  export type ToolSingletonScope = "section";
241
248
  /**
@@ -6,6 +6,22 @@
6
6
  */
7
7
  import { dynamicMessageKey } from "@pie-players/pie-players-shared/i18n/provider";
8
8
  import { normalizeToolAlias } from "./tools-config-normalizer.js";
9
+ /**
10
+ * The id a registration's provider registers under for one tool config: the
11
+ * descriptor's answer, then the config's `provider.id`, then the tool id. `null`
12
+ * when the registration carries no provider.
13
+ *
14
+ * The coordinator registers providers under this id and the section player looks
15
+ * for them in a host-supplied coordinator by it, so the two resolve it one way.
16
+ */
17
+ export function resolveToolProviderId(registration, config) {
18
+ const descriptor = registration.provider;
19
+ if (!descriptor)
20
+ return null;
21
+ return (descriptor.getProviderId?.(config) ??
22
+ config?.provider?.id ??
23
+ registration.toolId);
24
+ }
9
25
  /**
10
26
  * A registration's display name in the interface locale.
11
27
  *
@@ -13,16 +29,12 @@ import { normalizeToolAlias } from "./tools-config-normalizer.js";
13
29
  * falls back to `name` rather than rendering the key, so a catalog gap degrades
14
30
  * to English instead of to `tools.something.name` on a toolbar button.
15
31
  *
16
- * Both display resolvers live here so the toolbars, the settings panels and the
17
- * PNP debugger cannot each invent their own precedence.
32
+ * It lives here so the toolbars, the settings panels and the PNP debugger
33
+ * cannot each invent their own precedence.
18
34
  */
19
35
  export function resolveToolRegistrationName(registration, i18n) {
20
36
  return resolveKeyedString(registration.name, registration.nameKey, i18n);
21
37
  }
22
- /** A registration's description in the interface locale. See the name resolver. */
23
- export function resolveToolRegistrationDescription(registration, i18n) {
24
- return resolveKeyedString(registration.description, registration.descriptionKey, i18n);
25
- }
26
38
  function resolveKeyedString(source, key, i18n) {
27
39
  if (!key || !i18n)
28
40
  return source;
@@ -50,7 +62,7 @@ function assertNonEmptyString(value, fieldName) {
50
62
  }
51
63
  // Defence-in-depth: reject obvious XSS payloads in tool-registered icon
52
64
  // markup at registration time. Runtime rendering still runs each icon
53
- // through DOMPurify (see `ToolIcon.svelte`), but surfacing the problem
65
+ // through DOMPurify (`sanitizeSvgIcon` in `ItemToolBar.svelte`), but surfacing the problem
54
66
  // early produces a clearer error for tool authors than "the icon silently
55
67
  // disappeared after sanitization".
56
68
  const SCRIPTABLE_ICON_PATTERNS = [