@pie-players/pie-assessment-toolkit 0.3.67 → 0.3.69
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/README.md +81 -9
- package/dist/attempt/AssessmentSession.d.ts +7 -27
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-pe5szfyx.js +46 -0
- package/dist/components/chunks/ItemToolBar-rd7te9r0.js +51 -0
- package/dist/components/item-toolbar-element.js +1 -0
- package/dist/components/pie-assessment-toolkit-element.js +1 -0
- package/dist/components/section-toolbar-element.js +1 -0
- package/dist/context/assessment-toolkit-context.d.ts +28 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/policy/core/compose-decision.js +14 -0
- package/dist/policy/core/decision-types.d.ts +1 -1
- package/dist/policy/sources/PnpPolicySource.d.ts +8 -0
- package/dist/policy/sources/PnpPolicySource.js +19 -10
- package/dist/runtime/SectionRuntimeEngine.d.ts +56 -0
- package/dist/runtime/SectionRuntimeEngine.js +66 -1
- package/dist/runtime/core/engine-resolver.d.ts +25 -1
- package/dist/runtime/registration-events.d.ts +52 -0
- package/dist/runtime/registration-events.js +2 -0
- package/dist/services/AccessibilityCatalogResolver.js +21 -5
- package/dist/services/I18nService.d.ts +28 -100
- package/dist/services/I18nService.js +43 -233
- package/dist/services/TTSService.d.ts +12 -0
- package/dist/services/TTSService.js +18 -17
- package/dist/services/ToolRegistry.d.ts +120 -2
- package/dist/services/ToolRegistry.js +64 -0
- package/dist/services/ToolkitCoordinator.d.ts +39 -37
- package/dist/services/ToolkitCoordinator.js +40 -0
- package/dist/services/audio-handoff.d.ts +39 -0
- package/dist/services/audio-handoff.js +58 -0
- package/dist/services/catalog-media.d.ts +35 -4
- package/dist/services/catalog-media.js +92 -3
- package/dist/services/framework-error.d.ts +15 -1
- package/dist/services/interfaces.d.ts +28 -0
- package/dist/services/pnp-standard-features.d.ts +1 -1
- package/dist/services/section-controller-types.d.ts +218 -7
- package/dist/services/selection-action.d.ts +49 -0
- package/dist/services/selection-action.js +10 -0
- package/dist/services/spoken-audio-cards.js +5 -1
- package/dist/services/tool-context.d.ts +6 -5
- package/dist/services/tool-context.js +205 -155
- package/dist/services/tool-icons.d.ts +18 -0
- package/dist/services/tool-icons.js +31 -0
- package/dist/services/tool-providers/CortexToolProvider.d.ts +18 -0
- package/dist/services/tool-providers/CortexToolProvider.js +32 -0
- package/dist/services/tool-providers/DesmosToolProvider.d.ts +13 -101
- package/dist/services/tool-providers/DesmosToolProvider.js +14 -145
- package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +21 -0
- package/dist/services/tool-providers/GeoGebraToolProvider.js +32 -0
- package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +35 -0
- package/dist/services/tool-providers/LazyCalculatorToolProvider.js +95 -0
- package/dist/services/tool-providers/index.d.ts +4 -0
- package/dist/services/tool-providers/index.js +2 -0
- package/dist/services/tool-request.d.ts +106 -0
- package/dist/services/tool-request.js +127 -0
- package/dist/services/toolbar-items.d.ts +6 -0
- package/dist/tools/client.d.ts +0 -2
- package/dist/tools/client.js +0 -4
- package/dist/tools/internal.d.ts +7 -1
- package/dist/tools/internal.js +8 -1
- package/dist/tools/tool-surface-host.d.ts +57 -0
- package/dist/tools/tool-surface-host.js +610 -0
- package/dist/tools/types.d.ts +1 -66
- package/package.json +22 -12
- package/dist/components/chunks/ItemToolBar-8jgdz50p.js +0 -51
- package/dist/components/chunks/ItemToolBar-cvs646j3.js +0 -36
- package/dist/tools/calculators/desmos-provider.d.ts +0 -46
- package/dist/tools/calculators/desmos-provider.js +0 -393
- package/dist/tools/library-loader.d.ts +0 -62
- package/dist/tools/library-loader.js +0 -261
|
@@ -4,7 +4,37 @@
|
|
|
4
4
|
* Central registry for all assessment tools. Manages tool metadata, visibility logic,
|
|
5
5
|
* and button/instance creation. Supports dynamic registration and override by integrators.
|
|
6
6
|
*/
|
|
7
|
+
import { dynamicMessageKey } from "@pie-players/pie-players-shared/i18n/provider";
|
|
7
8
|
import { normalizeToolAlias } from "./tools-config-normalizer.js";
|
|
9
|
+
/**
|
|
10
|
+
* A registration's display name in the interface locale.
|
|
11
|
+
*
|
|
12
|
+
* Precedence: the resolved `nameKey`, then `name`. A key that does not resolve
|
|
13
|
+
* falls back to `name` rather than rendering the key, so a catalog gap degrades
|
|
14
|
+
* to English instead of to `tools.something.name` on a toolbar button.
|
|
15
|
+
*
|
|
16
|
+
* Both display resolvers live here so the toolbars, the settings panels and the
|
|
17
|
+
* PNP debugger cannot each invent their own precedence.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveToolRegistrationName(registration, i18n) {
|
|
20
|
+
return resolveKeyedString(registration.name, registration.nameKey, i18n);
|
|
21
|
+
}
|
|
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
|
+
function resolveKeyedString(source, key, i18n) {
|
|
27
|
+
if (!key || !i18n)
|
|
28
|
+
return source;
|
|
29
|
+
// A registration may be host-authored against a host catalog, so the key is
|
|
30
|
+
// not drawn from PIE's `MessageKey` union and has to be asserted.
|
|
31
|
+
const messageKey = dynamicMessageKey(key);
|
|
32
|
+
if (i18n.hasKey)
|
|
33
|
+
return i18n.hasKey(messageKey) ? i18n.t(messageKey) : source;
|
|
34
|
+
// A provider without `hasKey` still signals a miss by returning the key.
|
|
35
|
+
const resolved = i18n.t(messageKey);
|
|
36
|
+
return resolved === key ? source : resolved;
|
|
37
|
+
}
|
|
8
38
|
const VALID_TOOL_LEVELS = [
|
|
9
39
|
"assessment",
|
|
10
40
|
"section",
|
|
@@ -134,6 +164,10 @@ function assertToolRegistrationShape(registration) {
|
|
|
134
164
|
typeof registration.isVisibleInContext !== "function") {
|
|
135
165
|
throw new Error(`Invalid tool registration "${registration.toolId}": "isVisibleInContext" must be a function when present.`);
|
|
136
166
|
}
|
|
167
|
+
if (registration.isApplicableToContent !== undefined &&
|
|
168
|
+
typeof registration.isApplicableToContent !== "function") {
|
|
169
|
+
throw new Error(`Invalid tool registration "${registration.toolId}": "isApplicableToContent" must be a function when present.`);
|
|
170
|
+
}
|
|
137
171
|
if (registration.requiresAuthoredContent !== undefined) {
|
|
138
172
|
if (typeof registration.requiresAuthoredContent !== "object" ||
|
|
139
173
|
registration.requiresAuthoredContent === null ||
|
|
@@ -412,6 +446,36 @@ export class ToolRegistry {
|
|
|
412
446
|
}
|
|
413
447
|
return visible;
|
|
414
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Whether a tool can act on any of the contexts it would be placed against.
|
|
451
|
+
* Unlike the relevance pass this is a veto: a `false` here removes the tool
|
|
452
|
+
* from a toolbar even when a grant protects it, so a tool answers `false`
|
|
453
|
+
* only where its controls provably do nothing.
|
|
454
|
+
*
|
|
455
|
+
* A tool that declares no applicability gate is applicable. So is one
|
|
456
|
+
* evaluated against no contexts — content that has not resolved yet cannot
|
|
457
|
+
* establish that a tool is useless.
|
|
458
|
+
*
|
|
459
|
+
* @param toolId - Tool to ask
|
|
460
|
+
* @param contexts - Every context the tool could act on at this placement
|
|
461
|
+
*/
|
|
462
|
+
isApplicableToAnyContext(toolId, contexts) {
|
|
463
|
+
const tool = this.get(toolId);
|
|
464
|
+
if (!tool?.isApplicableToContent)
|
|
465
|
+
return true;
|
|
466
|
+
if (contexts.length === 0)
|
|
467
|
+
return true;
|
|
468
|
+
return contexts.some((context) => {
|
|
469
|
+
try {
|
|
470
|
+
return tool.isApplicableToContent?.(context) ?? true;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
console.error(`Error evaluating applicability for tool '${toolId}':`, error);
|
|
474
|
+
// A gate that throws has not established that the tool is useless.
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
415
479
|
/**
|
|
416
480
|
* Get tool metadata for building UIs
|
|
417
481
|
* Useful for building PNP configuration interfaces
|
|
@@ -24,13 +24,14 @@ import { FrameworkErrorBus, type FrameworkErrorListener } from "./framework-erro
|
|
|
24
24
|
import { HighlightCoordinator } from "./HighlightCoordinator.js";
|
|
25
25
|
import { ToolCoordinator } from "./ToolCoordinator.js";
|
|
26
26
|
import { TTSService } from "./TTSService.js";
|
|
27
|
-
import { type
|
|
27
|
+
import { type TTSRuntimeSettings } from "./tts-runtime-config.js";
|
|
28
28
|
import type { SREMathSpeechOptions } from "./tts/math-speech.js";
|
|
29
29
|
import { ToolProviderRegistry } from "./tool-providers/index.js";
|
|
30
30
|
import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
|
|
31
31
|
import type { TTSToolProviderConfig } from "./tool-providers/index.js";
|
|
32
32
|
import { ToolRegistry } from "./ToolRegistry.js";
|
|
33
33
|
import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap } from "./ToolRegistry.js";
|
|
34
|
+
import type { ToolOpenRequest, ToolRequestTarget } from "./tool-request.js";
|
|
34
35
|
import { type FeaturePolicyDecision, type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
|
|
35
36
|
import type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionSessionPersistenceStrategy, SectionPersistenceFactoryDefaults } from "./section-controller-types.js";
|
|
36
37
|
export type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionControllerLoadedRenderable, SectionSessionPersistenceConfig, SectionSessionPersistenceStrategy, SectionControllerRuntimeState, SectionControllerSessionState, SectionPersistenceFactoryDefaults, } from "./section-controller-types.js";
|
|
@@ -45,45 +46,25 @@ export interface ToolConfig {
|
|
|
45
46
|
[key: string]: unknown;
|
|
46
47
|
}
|
|
47
48
|
/**
|
|
48
|
-
* TTS
|
|
49
|
+
* TTS configuration as a host writes it.
|
|
50
|
+
*
|
|
51
|
+
* The field set is `TTSRuntimeSettings`, which the runtime resolver owns: the two
|
|
52
|
+
* were declared separately and had already drifted in both directions, so a field
|
|
53
|
+
* the runtime honoured could not be named here. What this adds is the two things
|
|
54
|
+
* only a host-facing config has — a place to stash unrecognised keys, and a
|
|
55
|
+
* callback for fetching provider credentials, neither of which the resolved
|
|
56
|
+
* runtime settings carry.
|
|
57
|
+
*
|
|
58
|
+
* An intersection rather than an interface: `ToolConfig.provider` is `unknown`
|
|
59
|
+
* where the runtime settings narrow it to the three provider ids, and an interface
|
|
60
|
+
* cannot inherit a member from two parents that type it differently.
|
|
49
61
|
*/
|
|
50
|
-
export
|
|
51
|
-
backend?: "browser" | "polly" | "google" | "server";
|
|
52
|
-
provider?: "polly" | "google" | "custom";
|
|
53
|
-
serverProvider?: "polly" | "google" | "custom";
|
|
54
|
-
engine?: "standard" | "neural";
|
|
55
|
-
sampleRate?: number;
|
|
56
|
-
format?: "mp3" | "ogg" | "pcm";
|
|
57
|
-
speechMarksMode?: "word" | "word+sentence";
|
|
58
|
-
defaultVoice?: string;
|
|
59
|
-
rate?: number;
|
|
60
|
-
pitch?: number;
|
|
61
|
-
apiEndpoint?: string;
|
|
62
|
-
language?: string;
|
|
63
|
-
transportMode?: "pie" | "custom";
|
|
64
|
-
endpointMode?: "synthesizePath" | "rootPost";
|
|
65
|
-
endpointValidationMode?: "voices" | "endpoint" | "none";
|
|
66
|
-
includeAuthOnAssetFetch?: boolean;
|
|
67
|
-
validateEndpoint?: boolean;
|
|
68
|
-
cache?: boolean;
|
|
69
|
-
speedRate?: "slow" | "medium" | "fast";
|
|
70
|
-
lang_id?: string;
|
|
71
|
-
/**
|
|
72
|
-
* Optional inline TTS speed buttons.
|
|
73
|
-
* - Omitted/non-array: default speed buttons are shown.
|
|
74
|
-
* - Empty array: hide speed buttons.
|
|
75
|
-
* - Arrays that sanitize to no valid values: default speed buttons are shown.
|
|
76
|
-
* - Object entries can customize button text while preserving numeric rates.
|
|
77
|
-
*/
|
|
78
|
-
speedOptions?: TTSSpeedOption[];
|
|
79
|
-
layoutMode?: TTSLayoutMode;
|
|
80
|
-
/** Speech Rule Engine options for generated MathML speech. */
|
|
81
|
-
mathSpeech?: SREMathSpeechOptions;
|
|
62
|
+
export type TTSToolConfig = ToolConfig & TTSRuntimeSettings & {
|
|
82
63
|
settings?: Record<string, unknown> & {
|
|
83
64
|
mathSpeech?: SREMathSpeechOptions;
|
|
84
65
|
};
|
|
85
66
|
authFetcher?: () => Promise<Partial<TTSToolProviderConfig>>;
|
|
86
|
-
}
|
|
67
|
+
};
|
|
87
68
|
/**
|
|
88
69
|
* Answer eliminator tool configuration
|
|
89
70
|
*/
|
|
@@ -205,8 +186,8 @@ export interface SectionEventSubscriptionArgs {
|
|
|
205
186
|
eventTypes?: readonly SectionControllerEventType[];
|
|
206
187
|
itemIds?: readonly string[];
|
|
207
188
|
}
|
|
208
|
-
export type SectionItemEventType = Exclude<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error">;
|
|
209
|
-
export type SectionScopedEventType = Extract<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error">;
|
|
189
|
+
export type SectionItemEventType = Exclude<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error" | "timed-media-cue-changed" | "timed-media-policy-degraded" | "timed-media-invalid">;
|
|
190
|
+
export type SectionScopedEventType = Extract<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error" | "timed-media-cue-changed" | "timed-media-policy-degraded" | "timed-media-invalid">;
|
|
210
191
|
export type SectionItemEvent = Extract<SectionControllerEvent, {
|
|
211
192
|
type: SectionItemEventType;
|
|
212
193
|
}>;
|
|
@@ -339,6 +320,7 @@ export declare class ToolkitCoordinator {
|
|
|
339
320
|
private readonly toolRegistry;
|
|
340
321
|
private readonly toolContextResolvers;
|
|
341
322
|
private readonly toolContextResolverChangeListeners;
|
|
323
|
+
private readonly toolRequests;
|
|
342
324
|
private readonly sectionControllers;
|
|
343
325
|
private readonly sectionControllerKeys;
|
|
344
326
|
private readonly sectionControllerInitPromises;
|
|
@@ -778,6 +760,26 @@ export declare class ToolkitCoordinator {
|
|
|
778
760
|
hasToolContextResolver(toolId: string): boolean;
|
|
779
761
|
resolveToolContext(context: ToolContextResolverContext): ResolvedToolContext | null;
|
|
780
762
|
onToolContextResolverChange(listener: () => void): () => void;
|
|
763
|
+
/**
|
|
764
|
+
* Claim requests for one placement level. Called by a toolbar on mount.
|
|
765
|
+
*/
|
|
766
|
+
registerToolRequestTarget(target: ToolRequestTarget): () => void;
|
|
767
|
+
/**
|
|
768
|
+
* Ask the toolbar hosting a tool to open it, handing it `params`.
|
|
769
|
+
*
|
|
770
|
+
* Returns whether a toolbar claimed the request. A surface offering this as an
|
|
771
|
+
* affordance should gate on {@link canRequestTool} first rather than acting on
|
|
772
|
+
* the return value, so the learner never sees a control that does nothing.
|
|
773
|
+
*/
|
|
774
|
+
requestTool(request: ToolOpenRequest): boolean;
|
|
775
|
+
/**
|
|
776
|
+
* Answers `false` for a tool this deployment does not carry rather than
|
|
777
|
+
* throwing as {@link requestTool} does. A composer asks this while rendering a
|
|
778
|
+
* surface, and a host that swapped the registry for one without the tool would
|
|
779
|
+
* otherwise lose the whole surface to an exception over an absent action.
|
|
780
|
+
*/
|
|
781
|
+
canRequestTool(toolId: string, level?: ToolOpenRequest["level"]): boolean;
|
|
782
|
+
onToolRequestTargetsChange(listener: () => void): () => void;
|
|
781
783
|
/**
|
|
782
784
|
* Compute the effective PNP/profile enforcement mode given the explicit
|
|
783
785
|
* host override and the auto-mode helper.
|
|
@@ -26,6 +26,7 @@ import { BrowserTTSProvider } from "./tts/browser-provider.js";
|
|
|
26
26
|
import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
|
|
27
27
|
import { ToolProviderRegistry } from "./tool-providers/index.js";
|
|
28
28
|
import { ToolRegistry } from "./ToolRegistry.js";
|
|
29
|
+
import { ToolRequestRegistry } from "./tool-request.js";
|
|
29
30
|
import { ToolPolicyEngine, } from "../policy/engine.js";
|
|
30
31
|
import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
|
|
31
32
|
const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -59,6 +60,9 @@ const SECTION_SCOPED_EVENT_TYPES = [
|
|
|
59
60
|
"section-loading-complete",
|
|
60
61
|
"section-items-complete-changed",
|
|
61
62
|
"section-error",
|
|
63
|
+
"timed-media-cue-changed",
|
|
64
|
+
"timed-media-policy-degraded",
|
|
65
|
+
"timed-media-invalid",
|
|
62
66
|
];
|
|
63
67
|
/**
|
|
64
68
|
* ToolkitCoordinator - Orchestrates all assessment toolkit services
|
|
@@ -113,6 +117,7 @@ export class ToolkitCoordinator {
|
|
|
113
117
|
toolRegistry;
|
|
114
118
|
toolContextResolvers = new Map();
|
|
115
119
|
toolContextResolverChangeListeners = new Set();
|
|
120
|
+
toolRequests = new ToolRequestRegistry();
|
|
116
121
|
sectionControllers = new Map();
|
|
117
122
|
sectionControllerKeys = new Map();
|
|
118
123
|
sectionControllerInitPromises = new Map();
|
|
@@ -1741,6 +1746,41 @@ export class ToolkitCoordinator {
|
|
|
1741
1746
|
this.toolContextResolverChangeListeners.delete(listener);
|
|
1742
1747
|
};
|
|
1743
1748
|
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Claim requests for one placement level. Called by a toolbar on mount.
|
|
1751
|
+
*/
|
|
1752
|
+
registerToolRequestTarget(target) {
|
|
1753
|
+
return this.toolRequests.registerTarget(target);
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Ask the toolbar hosting a tool to open it, handing it `params`.
|
|
1757
|
+
*
|
|
1758
|
+
* Returns whether a toolbar claimed the request. A surface offering this as an
|
|
1759
|
+
* affordance should gate on {@link canRequestTool} first rather than acting on
|
|
1760
|
+
* the return value, so the learner never sees a control that does nothing.
|
|
1761
|
+
*/
|
|
1762
|
+
requestTool(request) {
|
|
1763
|
+
this.assertCanonicalToolId(request.toolId);
|
|
1764
|
+
return this.toolRequests.request(request);
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Answers `false` for a tool this deployment does not carry rather than
|
|
1768
|
+
* throwing as {@link requestTool} does. A composer asks this while rendering a
|
|
1769
|
+
* surface, and a host that swapped the registry for one without the tool would
|
|
1770
|
+
* otherwise lose the whole surface to an exception over an absent action.
|
|
1771
|
+
*/
|
|
1772
|
+
canRequestTool(toolId, level) {
|
|
1773
|
+
try {
|
|
1774
|
+
this.assertCanonicalToolId(toolId);
|
|
1775
|
+
}
|
|
1776
|
+
catch {
|
|
1777
|
+
return false;
|
|
1778
|
+
}
|
|
1779
|
+
return this.toolRequests.canRequest(toolId, level);
|
|
1780
|
+
}
|
|
1781
|
+
onToolRequestTargetsChange(listener) {
|
|
1782
|
+
return this.toolRequests.onTargetsChange(listener);
|
|
1783
|
+
}
|
|
1744
1784
|
/**
|
|
1745
1785
|
* Compute the effective PNP/profile enforcement mode given the explicit
|
|
1746
1786
|
* host override and the auto-mode helper.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio handoff between read-aloud and a media surface.
|
|
3
|
+
*
|
|
4
|
+
* One learner, one pair of ears: read-aloud and media audio must never run at
|
|
5
|
+
* once, and the action the learner just took wins. Every media surface in the
|
|
6
|
+
* toolkit's graph needs both halves of that rule — pause yourself when speech
|
|
7
|
+
* starts, pause speech when you start — and there are already two such surfaces
|
|
8
|
+
* (the signing region, a timed-media stimulus reached through its port) with
|
|
9
|
+
* nothing in common but this. Shared so the rule has one statement: which states
|
|
10
|
+
* count as speaking is the part that would drift.
|
|
11
|
+
*
|
|
12
|
+
* Neither half resumes what it silenced. The learner presses play.
|
|
13
|
+
*/
|
|
14
|
+
import type { TtsServiceApi } from "./interfaces.js";
|
|
15
|
+
/** Only what a handoff needs, so a host passing a partial service still works. */
|
|
16
|
+
type TtsHandoffSubscriber = Pick<TtsServiceApi, "onStateChange" | "offStateChange">;
|
|
17
|
+
type TtsHandoffPlayback = Pick<TtsServiceApi, "isPlaying" | "pause">;
|
|
18
|
+
/**
|
|
19
|
+
* Silence a media surface whenever read-aloud starts speaking.
|
|
20
|
+
*
|
|
21
|
+
* `LOADING` as well as `PLAYING`, so the pause lands before the first word rather
|
|
22
|
+
* than a provider round-trip after it. A `LOADING` that then fails leaves media
|
|
23
|
+
* paused, which costs the learner one press of play.
|
|
24
|
+
*
|
|
25
|
+
* Returns the teardown, including where there was nothing to bind.
|
|
26
|
+
*/
|
|
27
|
+
export declare function bindTtsAudioHandoff(args: {
|
|
28
|
+
ttsService: Partial<TtsHandoffSubscriber> | null | undefined;
|
|
29
|
+
/** Unique per surface: the service keys its listener sets by this. */
|
|
30
|
+
listenerId: string;
|
|
31
|
+
silence: () => void;
|
|
32
|
+
}): () => void;
|
|
33
|
+
/**
|
|
34
|
+
* The other half: media audio has started, so read-aloud yields.
|
|
35
|
+
*
|
|
36
|
+
* Paused rather than stopped, so the learner keeps their place in the passage.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pauseTtsForMediaAudio(ttsService: Partial<TtsHandoffPlayback> | null | undefined): void;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio handoff between read-aloud and a media surface.
|
|
3
|
+
*
|
|
4
|
+
* One learner, one pair of ears: read-aloud and media audio must never run at
|
|
5
|
+
* once, and the action the learner just took wins. Every media surface in the
|
|
6
|
+
* toolkit's graph needs both halves of that rule — pause yourself when speech
|
|
7
|
+
* starts, pause speech when you start — and there are already two such surfaces
|
|
8
|
+
* (the signing region, a timed-media stimulus reached through its port) with
|
|
9
|
+
* nothing in common but this. Shared so the rule has one statement: which states
|
|
10
|
+
* count as speaking is the part that would drift.
|
|
11
|
+
*
|
|
12
|
+
* Neither half resumes what it silenced. The learner presses play.
|
|
13
|
+
*/
|
|
14
|
+
import { PlaybackState } from "./TTSService.js";
|
|
15
|
+
const NOOP = () => { };
|
|
16
|
+
/**
|
|
17
|
+
* Silence a media surface whenever read-aloud starts speaking.
|
|
18
|
+
*
|
|
19
|
+
* `LOADING` as well as `PLAYING`, so the pause lands before the first word rather
|
|
20
|
+
* than a provider round-trip after it. A `LOADING` that then fails leaves media
|
|
21
|
+
* paused, which costs the learner one press of play.
|
|
22
|
+
*
|
|
23
|
+
* Returns the teardown, including where there was nothing to bind.
|
|
24
|
+
*/
|
|
25
|
+
export function bindTtsAudioHandoff(args) {
|
|
26
|
+
const { ttsService, listenerId, silence } = args;
|
|
27
|
+
if (typeof ttsService?.onStateChange !== "function")
|
|
28
|
+
return NOOP;
|
|
29
|
+
const onTtsState = (state) => {
|
|
30
|
+
if (state !== PlaybackState.PLAYING && state !== PlaybackState.LOADING) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
silence();
|
|
34
|
+
};
|
|
35
|
+
ttsService.onStateChange(listenerId, onTtsState);
|
|
36
|
+
return () => {
|
|
37
|
+
try {
|
|
38
|
+
ttsService.offStateChange?.(listenerId, onTtsState);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// A torn-down service is not a failure to detach from.
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The other half: media audio has started, so read-aloud yields.
|
|
47
|
+
*
|
|
48
|
+
* Paused rather than stopped, so the learner keeps their place in the passage.
|
|
49
|
+
*/
|
|
50
|
+
export function pauseTtsForMediaAudio(ttsService) {
|
|
51
|
+
try {
|
|
52
|
+
if (ttsService?.isPlaying?.())
|
|
53
|
+
ttsService.pause?.();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A torn-down or uninitialized TTS service must not break playback.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -12,14 +12,45 @@
|
|
|
12
12
|
* Part of PIE Assessment Toolkit.
|
|
13
13
|
*/
|
|
14
14
|
import type { MediaFragmentRange, MediaSource } from "@pie-players/pie-players-shared/types";
|
|
15
|
+
/**
|
|
16
|
+
* The only `MediaAssetRef.version` this build renders.
|
|
17
|
+
*
|
|
18
|
+
* `media-asset-contract.md` requires unknown-version rejection for runtime
|
|
19
|
+
* rendering: bumping to `2` obliges every consumer to accept both for as long as
|
|
20
|
+
* any producer emits `1`, so a card claiming a version this build does not
|
|
21
|
+
* implement must not be rendered on a guess at which fields still mean what they
|
|
22
|
+
* did.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SUPPORTED_MEDIA_ASSET_VERSION = 1;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a media reference claims a version this build cannot render.
|
|
27
|
+
*
|
|
28
|
+
* An absent `version` is accepted rather than rejected — the same posture the
|
|
29
|
+
* rest of this module takes toward absent fields, and the one `media.kind` and
|
|
30
|
+
* `matchesRequestedSignLanguage` already take: only a positive claim of
|
|
31
|
+
* something else is refused. Producers predate the field, and treating its
|
|
32
|
+
* absence as a rejection would drop cards that are otherwise wholly valid.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isUnsupportedMediaAssetVersion(version: unknown): boolean;
|
|
15
35
|
export declare function isSafeMediaSrc(raw: unknown): raw is string;
|
|
16
36
|
export declare function normalizeMediaSources(raw: unknown): MediaSource[];
|
|
17
37
|
export declare function normalizeMediaFragment(raw: unknown): MediaFragmentRange | undefined;
|
|
18
38
|
export declare function trimmedOrUndefined(value: unknown): string | undefined;
|
|
19
39
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
40
|
+
* Hold a media element to one fragment's range, and return the disposer.
|
|
41
|
+
*
|
|
42
|
+
* The `#t=` URI `applyMediaFragment` writes is a hint browsers honour at neither
|
|
43
|
+
* bound reliably, so every consumer that means "play only this slice" enforces it
|
|
44
|
+
* here instead of reimplementing the pair. Both shipped consumers do —
|
|
45
|
+
* `SignLanguageMediaRegion` pausing at the end, `TTSService.playRecordedAudio`
|
|
46
|
+
* ending the clip so the chunk sequence advances — which is why the end action is
|
|
47
|
+
* the caller's and only the arithmetic is shared.
|
|
48
|
+
*
|
|
49
|
+
* The start seek is forward only: a browser that did honour the URI has already
|
|
50
|
+
* positioned past the start, and seeking back would replay audio the learner has
|
|
51
|
+
* heard. `onReachedEnd` may fire more than once and must tolerate it; polling
|
|
52
|
+
* stops while the element is paused, so an element left sitting past its end bound
|
|
53
|
+
* goes quiet rather than being told repeatedly.
|
|
24
54
|
*/
|
|
55
|
+
export declare function enforceMediaFragment(element: HTMLMediaElement, fragment: MediaFragmentRange | undefined, onReachedEnd: () => void): () => void;
|
|
25
56
|
export declare function applyMediaFragment(src: string, fragment?: MediaFragmentRange): string;
|
|
@@ -20,6 +20,28 @@
|
|
|
20
20
|
*/
|
|
21
21
|
const DISALLOWED_SRC_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
22
22
|
const ALLOWED_SRC_SCHEMES = new Set(["http:", "https:", "data:", "blob:"]);
|
|
23
|
+
/**
|
|
24
|
+
* The only `MediaAssetRef.version` this build renders.
|
|
25
|
+
*
|
|
26
|
+
* `media-asset-contract.md` requires unknown-version rejection for runtime
|
|
27
|
+
* rendering: bumping to `2` obliges every consumer to accept both for as long as
|
|
28
|
+
* any producer emits `1`, so a card claiming a version this build does not
|
|
29
|
+
* implement must not be rendered on a guess at which fields still mean what they
|
|
30
|
+
* did.
|
|
31
|
+
*/
|
|
32
|
+
export const SUPPORTED_MEDIA_ASSET_VERSION = 1;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a media reference claims a version this build cannot render.
|
|
35
|
+
*
|
|
36
|
+
* An absent `version` is accepted rather than rejected — the same posture the
|
|
37
|
+
* rest of this module takes toward absent fields, and the one `media.kind` and
|
|
38
|
+
* `matchesRequestedSignLanguage` already take: only a positive claim of
|
|
39
|
+
* something else is refused. Producers predate the field, and treating its
|
|
40
|
+
* absence as a rejection would drop cards that are otherwise wholly valid.
|
|
41
|
+
*/
|
|
42
|
+
export function isUnsupportedMediaAssetVersion(version) {
|
|
43
|
+
return version !== undefined && version !== SUPPORTED_MEDIA_ASSET_VERSION;
|
|
44
|
+
}
|
|
23
45
|
export function isSafeMediaSrc(raw) {
|
|
24
46
|
if (typeof raw !== "string")
|
|
25
47
|
return false;
|
|
@@ -85,10 +107,77 @@ export function trimmedOrUndefined(value) {
|
|
|
85
107
|
}
|
|
86
108
|
/**
|
|
87
109
|
* Apply a fragment range to a source URL as a Media Fragments URI, so one
|
|
88
|
-
* recording can serve several content nodes.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
110
|
+
* recording can serve several content nodes.
|
|
111
|
+
*
|
|
112
|
+
* The URI is a hint only: browsers honour both bounds inconsistently, so the
|
|
113
|
+
* caller enforces the range itself — seek forward to the start once metadata is
|
|
114
|
+
* available, and stop at the end. `SignLanguageMediaRegion` and
|
|
115
|
+
* `TTSService.playRecordedAudio` are the two shipped consumers that do so.
|
|
116
|
+
*/
|
|
117
|
+
/**
|
|
118
|
+
* How often the end bound is re-checked while a slice is playing. `timeupdate`
|
|
119
|
+
* alone fires about four times a second, which is loose enough to leak a sliver
|
|
120
|
+
* of the next node's recording.
|
|
121
|
+
*/
|
|
122
|
+
const END_CHECK_INTERVAL_MS = 100;
|
|
123
|
+
/** `HTMLMediaElement.HAVE_METADATA`, which not every DOM implementation exposes. */
|
|
124
|
+
const HAVE_METADATA = 1;
|
|
125
|
+
/**
|
|
126
|
+
* Hold a media element to one fragment's range, and return the disposer.
|
|
127
|
+
*
|
|
128
|
+
* The `#t=` URI `applyMediaFragment` writes is a hint browsers honour at neither
|
|
129
|
+
* bound reliably, so every consumer that means "play only this slice" enforces it
|
|
130
|
+
* here instead of reimplementing the pair. Both shipped consumers do —
|
|
131
|
+
* `SignLanguageMediaRegion` pausing at the end, `TTSService.playRecordedAudio`
|
|
132
|
+
* ending the clip so the chunk sequence advances — which is why the end action is
|
|
133
|
+
* the caller's and only the arithmetic is shared.
|
|
134
|
+
*
|
|
135
|
+
* The start seek is forward only: a browser that did honour the URI has already
|
|
136
|
+
* positioned past the start, and seeking back would replay audio the learner has
|
|
137
|
+
* heard. `onReachedEnd` may fire more than once and must tolerate it; polling
|
|
138
|
+
* stops while the element is paused, so an element left sitting past its end bound
|
|
139
|
+
* goes quiet rather than being told repeatedly.
|
|
91
140
|
*/
|
|
141
|
+
export function enforceMediaFragment(element, fragment, onReachedEnd) {
|
|
142
|
+
if (!fragment)
|
|
143
|
+
return () => { };
|
|
144
|
+
const { startSeconds, endSeconds } = fragment;
|
|
145
|
+
let seekToStart;
|
|
146
|
+
if (startSeconds > 0) {
|
|
147
|
+
seekToStart = () => {
|
|
148
|
+
if (element.currentTime < startSeconds) {
|
|
149
|
+
element.currentTime = startSeconds;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
// Seeking before metadata is available only sets the default start
|
|
153
|
+
// position, so wait for it unless it already landed.
|
|
154
|
+
if (element.readyState >= HAVE_METADATA)
|
|
155
|
+
seekToStart();
|
|
156
|
+
else
|
|
157
|
+
element.addEventListener("loadedmetadata", seekToStart);
|
|
158
|
+
}
|
|
159
|
+
let checkEnd;
|
|
160
|
+
let endPoll;
|
|
161
|
+
if (endSeconds !== undefined) {
|
|
162
|
+
checkEnd = () => {
|
|
163
|
+
if (element.currentTime >= endSeconds)
|
|
164
|
+
onReachedEnd();
|
|
165
|
+
};
|
|
166
|
+
element.addEventListener("timeupdate", checkEnd);
|
|
167
|
+
endPoll = setInterval(() => {
|
|
168
|
+
if (!element.paused)
|
|
169
|
+
checkEnd?.();
|
|
170
|
+
}, END_CHECK_INTERVAL_MS);
|
|
171
|
+
}
|
|
172
|
+
return () => {
|
|
173
|
+
if (seekToStart)
|
|
174
|
+
element.removeEventListener("loadedmetadata", seekToStart);
|
|
175
|
+
if (checkEnd)
|
|
176
|
+
element.removeEventListener("timeupdate", checkEnd);
|
|
177
|
+
if (endPoll !== undefined)
|
|
178
|
+
clearInterval(endPoll);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
92
181
|
export function applyMediaFragment(src, fragment) {
|
|
93
182
|
if (!fragment)
|
|
94
183
|
return src;
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
|
|
2
|
-
export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "tool-surface"
|
|
2
|
+
export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "tool-surface"
|
|
3
|
+
/**
|
|
4
|
+
* A timed-media section could not deliver a policy as authored: a media time
|
|
5
|
+
* source missing `canPause` / `canRestrictSeeking` (recoverable — cues still
|
|
6
|
+
* fire and state is still recorded, only enforcement is lost), or authored
|
|
7
|
+
* `timedMedia` that failed validation (not recoverable — the section delivers
|
|
8
|
+
* as an ordinary section instead).
|
|
9
|
+
*/
|
|
10
|
+
| "timed-media"
|
|
11
|
+
/**
|
|
12
|
+
* An interface-locale catalog failed to load. Recoverable by construction: every
|
|
13
|
+
* key still resolves through the English fallback chain, so the player renders
|
|
14
|
+
* — in the wrong language, which a host wants to know about.
|
|
15
|
+
*/
|
|
16
|
+
| "i18n-locale-load" | "unknown";
|
|
3
17
|
export type FrameworkErrorSeverity = "warning" | "error";
|
|
4
18
|
export interface FrameworkErrorModel {
|
|
5
19
|
kind: FrameworkErrorKind;
|
|
@@ -25,6 +25,7 @@ import type { FeaturePolicyDecision, PolicySource, PnpEnforcementMode, ResolvedE
|
|
|
25
25
|
import type { AssessmentEntity, AssessmentItemRef } from "@pie-players/pie-players-shared/types";
|
|
26
26
|
import type { ITTSProvider, TTSProviderCapabilities } from "@pie-players/pie-tts";
|
|
27
27
|
import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap } from "./ToolRegistry.js";
|
|
28
|
+
import type { ToolOpenRequest, ToolRequestTarget } from "./tool-request.js";
|
|
28
29
|
export type { I18nServiceApi };
|
|
29
30
|
/**
|
|
30
31
|
* Theme provider interface
|
|
@@ -702,4 +703,31 @@ export interface ToolkitCoordinatorApi {
|
|
|
702
703
|
* Subscribe to resolver registration/removal changes.
|
|
703
704
|
*/
|
|
704
705
|
onToolContextResolverChange(listener: () => void): () => void;
|
|
706
|
+
/**
|
|
707
|
+
* The tool-open request seam, optional as a group.
|
|
708
|
+
*
|
|
709
|
+
* `ToolkitCoordinator` implements all four. They are optional here because a host
|
|
710
|
+
* may supply a coordinator of its own that predates the seam, and a surface that
|
|
711
|
+
* pairs a selection action to a tool already has to check before offering a button
|
|
712
|
+
* it cannot service. Declaring them required would make such a coordinator
|
|
713
|
+
* structurally non-conformant while every call site guards anyway.
|
|
714
|
+
*/
|
|
715
|
+
/** Claim tool-open requests for one placement level. Toolbars call this. */
|
|
716
|
+
registerToolRequestTarget?(target: ToolRequestTarget): () => void;
|
|
717
|
+
/**
|
|
718
|
+
* Ask the toolbar hosting a tool to open it with `params`, and report whether
|
|
719
|
+
* one claimed the request. Lets a selection gateway hand the learner's
|
|
720
|
+
* selection to a tool it neither mounts nor can name a scoped instance of.
|
|
721
|
+
*/
|
|
722
|
+
requestTool?(request: ToolOpenRequest): boolean;
|
|
723
|
+
/**
|
|
724
|
+
* Whether a request for this tool would reach a toolbar. A surface asks before
|
|
725
|
+
* offering the affordance.
|
|
726
|
+
*/
|
|
727
|
+
canRequestTool?(toolId: string, level?: ToolOpenRequest["level"]): boolean;
|
|
728
|
+
/**
|
|
729
|
+
* Subscribe to toolbar registration/removal, so a surface can re-evaluate the
|
|
730
|
+
* actions it offers.
|
|
731
|
+
*/
|
|
732
|
+
onToolRequestTargetsChange?(listener: () => void): () => void;
|
|
705
733
|
}
|
|
@@ -167,7 +167,7 @@ export declare const QTI_STANDARD_ACCESS_FEATURES: {
|
|
|
167
167
|
/**
|
|
168
168
|
* Flat list of all standard access features for validation
|
|
169
169
|
*/
|
|
170
|
-
export declare const ALL_STANDARD_ACCESS_FEATURES: ("transcript" | "braille" | "textToSpeech" | "calculator" | "
|
|
170
|
+
export declare const ALL_STANDARD_ACCESS_FEATURES: ("transcript" | "braille" | "textToSpeech" | "calculator" | "graph" | "periodicTable" | "ruler" | "protractor" | "answerEliminator" | "signLanguage" | "readAloud" | "strikethrough" | "magnification" | "screenMagnifier" | "zoomable" | "highContrastDisplay" | "highContrastAudio" | "colorContrast" | "invertColors" | "displayTransformability" | "largePrint" | "fontEnlargement" | "resizeText" | "alternativeText" | "longDescription" | "describedMath" | "tactileGraphic" | "tactileObject" | "audioDescription" | "humanVoice" | "syntheticVoice" | "speechRate" | "speechVolume" | "voicePitch" | "captions" | "closedCaptions" | "openCaptions" | "subtitles" | "audioControl" | "noBackgroundAudio" | "keyboardControl" | "mouseControl" | "touchControl" | "voiceControl" | "switchControl" | "eyeGazeControl" | "singleSwitchAccess" | "stickyKeys" | "keyboardShortcuts" | "timingControl" | "unlimitedTime" | "extendedTime" | "pauseControl" | "simplifiedLanguage" | "reducedComplexity" | "structuralNavigation" | "tableOfContents" | "reducedDistraction" | "noFlashing" | "pauseAnimation" | "annotations" | "bookmarking" | "highlighting" | "guidedNavigation" | "thesaurus" | "spellingAssistance" | "grammarAssistance" | "lineSpacing" | "wordSpacing" | "letterSpacing" | "fontFamily" | "readingMask" | "readingGuide" | "readingRuler" | "wordHighlighting" | "lineHighlighting" | "focusIndicator" | "printableResource" | "nemeth" | "refreshableBraille" | "index" | "pageNavigation" | "skipContent" | "breadcrumbs" | "searchable" | "fullTextSearch" | "multilingualText" | "translatedText" | "glossary" | "signLanguageInterpretation" | "visualLanguage" | "graphingCalculator" | "formulaSheet" | "answerMasking" | "itemGlossary" | "tutorialAvailable")[];
|
|
171
171
|
/**
|
|
172
172
|
* Example PNP configurations for common accessibility needs
|
|
173
173
|
* These are NOT official profiles but illustrative examples showing
|