@pie-players/pie-players-shared 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.
- package/README.md +27 -1
- package/dist/components/vendor/nds/README.md +7 -5
- package/dist/config/profile.d.ts +0 -2
- package/dist/config/profile.js +2 -8
- package/dist/i18n/index.d.ts +2 -3
- package/dist/i18n/index.js +2 -3
- package/dist/i18n/messages/en-US.d.ts +10 -0
- package/dist/i18n/messages/en-US.js +12 -2
- package/dist/i18n/messages/nl-NL.d.ts +6 -0
- package/dist/i18n/messages/nl-NL.js +8 -2
- package/dist/i18n/provider.d.ts +4 -5
- package/dist/i18n/provider.js +4 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/instrumentation/index.d.ts +5 -4
- package/dist/instrumentation/index.js +5 -4
- package/dist/instrumentation/new-relic-agent.d.ts +20 -0
- package/dist/instrumentation/new-relic-agent.js +24 -0
- package/dist/instrumentation/providers/BaseInstrumentationProvider.d.ts +5 -4
- package/dist/instrumentation/providers/NewRelicInstrumentationProvider.d.ts +7 -7
- package/dist/instrumentation/providers/NewRelicInstrumentationProvider.js +21 -32
- package/dist/instrumentation/types.d.ts +7 -3
- package/dist/loader-config.d.ts +16 -6
- package/dist/loaders/controller-loading.d.ts +14 -0
- package/dist/loaders/controller-loading.js +12 -0
- package/dist/loaders/defaults.d.ts +10 -0
- package/dist/loaders/defaults.js +10 -0
- package/dist/loaders/element-loader.d.ts +20 -8
- package/dist/loaders/element-loader.js +102 -36
- package/dist/loaders/element-package-policy.d.ts +2 -0
- package/dist/loaders/element-package-policy.js +2 -1
- package/dist/loaders/esm-adapter.d.ts +18 -0
- package/dist/loaders/esm-adapter.js +617 -102
- package/dist/loaders/iife-adapter.d.ts +0 -5
- package/dist/loaders/iife-adapter.js +3 -9
- package/dist/loaders/index.d.ts +10 -3
- package/dist/loaders/index.js +8 -3
- package/dist/loaders/mathjax-version-conflict.d.ts +17 -0
- package/dist/loaders/mathjax-version-conflict.js +51 -0
- package/dist/loaders/module-shim.d.ts +12 -0
- package/dist/loaders/module-shim.js +12 -0
- package/dist/loaders/preloaded-alignment.d.ts +24 -0
- package/dist/loaders/preloaded-alignment.js +57 -0
- package/dist/loaders/preloaded-authored-tags.d.ts +26 -0
- package/dist/loaders/preloaded-authored-tags.js +69 -0
- package/dist/loaders/preloaded-registration.d.ts +56 -0
- package/dist/loaders/preloaded-registration.js +129 -0
- package/dist/media/index.d.ts +69 -0
- package/dist/media/index.js +194 -0
- package/dist/pie/authoring.d.ts +1 -1
- package/dist/pie/authoring.js +25 -18
- package/dist/pie/element-announcements.d.ts +39 -0
- package/dist/pie/element-announcements.js +67 -0
- package/dist/pie/element-observer.d.ts +3 -1
- package/dist/pie/element-observer.js +1 -0
- package/dist/pie/index.d.ts +6 -4
- package/dist/pie/index.js +6 -3
- package/dist/pie/initialization.js +13 -22
- package/dist/pie/initialize-element.d.ts +3 -0
- package/dist/pie/initialize-element.js +3 -4
- package/dist/pie/instrumentation-event-map.js +2 -9
- package/dist/pie/instrumentation-provider-resolution.d.ts +2 -1
- package/dist/pie/instrumentation-provider-resolution.js +0 -1
- package/dist/pie/item-session-contract.d.ts +47 -0
- package/dist/pie/item-session-contract.js +196 -9
- package/dist/pie/markup-math.d.ts +20 -0
- package/dist/pie/markup-math.js +118 -0
- package/dist/pie/math-rendering.d.ts +10 -21
- package/dist/pie/math-rendering.js +50 -35
- package/dist/pie/registry.d.ts +13 -1
- package/dist/pie/registry.js +39 -1
- package/dist/pie/resource-monitor.d.ts +6 -33
- package/dist/pie/resource-monitor.js +14 -213
- package/dist/pie/scoring.d.ts +11 -2
- package/dist/pie/scoring.js +18 -16
- package/dist/pie/session-commit.d.ts +121 -0
- package/dist/pie/session-commit.js +420 -0
- package/dist/pie/session-snapshot.d.ts +80 -0
- package/dist/pie/session-snapshot.js +166 -0
- package/dist/pie/tag-names.d.ts +1 -0
- package/dist/pie/tag-names.js +1 -0
- package/dist/pie/types.d.ts +4 -5
- package/dist/pie/types.js +1 -0
- package/dist/pie/updates.d.ts +2 -2
- package/dist/pie/updates.js +33 -15
- package/dist/pie/versioned-tag.d.ts +4 -0
- package/dist/pie/versioned-tag.js +4 -0
- package/dist/security/index.d.ts +2 -3
- package/dist/security/index.js +2 -3
- package/dist/security/sanitize-item-markup.d.ts +0 -2
- package/dist/security/sanitize-item-markup.js +0 -4
- package/dist/security/sanitize-svg-icon.d.ts +0 -2
- package/dist/security/sanitize-svg-icon.js +0 -5
- package/dist/security/wrap-overwide.d.ts +5 -0
- package/dist/security/wrap-overwide.js +16 -0
- package/dist/timed-media/policy.js +5 -16
- package/dist/types/index.d.ts +30 -311
- package/dist/types/index.js +0 -68
- package/dist/ui/attribute-coercion.d.ts +12 -0
- package/dist/ui/attribute-coercion.js +33 -0
- package/dist/ui/content-styles.d.ts +18 -16
- package/dist/ui/content-styles.js +67 -36
- package/dist/ui/overlay-containment.d.ts +31 -0
- package/dist/ui/overlay-containment.js +63 -0
- package/dist/ui/overlay-placement.d.ts +110 -0
- package/dist/ui/overlay-placement.js +222 -0
- package/dist/ui/pointer-gesture.d.ts +32 -0
- package/dist/ui/pointer-gesture.js +56 -0
- package/dist/ui/pointer-rotate.d.ts +29 -0
- package/dist/ui/pointer-rotate.js +39 -0
- package/package.json +21 -17
- package/dist/pie/component-context.d.ts +0 -21
- package/dist/pie/component-context.js +0 -29
- package/dist/pie/player-initializer.d.ts +0 -54
- package/dist/pie/player-initializer.js +0 -122
- package/dist/security/wrap-model-rich-content.d.ts +0 -1
- package/dist/security/wrap-model-rich-content.js +0 -40
- package/dist/server/npm-auth-env.d.ts +0 -13
- package/dist/server/npm-auth-env.js +0 -64
- package/dist/server/npm-registry.d.ts +0 -7
- package/dist/server/npm-registry.js +0 -59
- package/dist/ui/debug-panel-persistence.d.ts +0 -17
- package/dist/ui/debug-panel-persistence.js +0 -37
package/dist/pie/scoring.js
CHANGED
|
@@ -10,9 +10,19 @@ import { findOrAddSession } from "./utils.js";
|
|
|
10
10
|
// Create module-level logger (respects global debug flag - pass function for dynamic checking)
|
|
11
11
|
const logger = createPieLogger("pie-scoring", () => isGlobalDebugEnabled());
|
|
12
12
|
/**
|
|
13
|
-
* Find the controller for a PIE element
|
|
13
|
+
* Find the controller for a PIE element.
|
|
14
|
+
*
|
|
15
|
+
* `deliveryBundleType` is the bundle type of the player asking. A `player.js`
|
|
16
|
+
* delivery, which is what a hosted player is, resolves no controller: its
|
|
17
|
+
* models and scores come from the server, and the registry is shared with
|
|
18
|
+
* every other loader on the page, so a registered controller is no evidence
|
|
19
|
+
* that this player may run one.
|
|
14
20
|
*/
|
|
15
|
-
export const findPieController = (elementName) => {
|
|
21
|
+
export const findPieController = (elementName, deliveryBundleType) => {
|
|
22
|
+
if (deliveryBundleType === BundleType.player) {
|
|
23
|
+
logger.debug(`[findPieController] ℹ️ ${elementName} delivered as player.js; using server-processed models`);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
16
26
|
const registry = pieRegistry();
|
|
17
27
|
logger.debug(`[findPieController] Looking for controller for: ${elementName}`);
|
|
18
28
|
logger.debug(`[findPieController] Registry has entries for:`, Object.keys(registry));
|
|
@@ -30,20 +40,12 @@ export const findPieController = (elementName) => {
|
|
|
30
40
|
controllerKeys: entry.controller ? Object.keys(entry.controller) : [],
|
|
31
41
|
bundleType: entry.bundleType,
|
|
32
42
|
});
|
|
33
|
-
|
|
43
|
+
// Registration writes `null` for a package without a controller.
|
|
44
|
+
const controller = entry.controller ?? undefined;
|
|
34
45
|
if (!controller) {
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
logger.error(`[findPieController] ❌ CRITICAL: Registry entry exists for ${elementName} but controller is missing!`);
|
|
39
|
-
logger.error(`[findPieController] Bundle type: ${entry.bundleType} (controllers required)`);
|
|
40
|
-
logger.error(`[findPieController] Entry:`, entry);
|
|
41
|
-
throw new Error(`No controller found for ${elementName}. client-player.js bundles MUST include controllers. Check bundle loading and registration.`);
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
// player.js doesn't have controllers - this is expected
|
|
45
|
-
logger.debug(`[findPieController] ℹ️ No controller for ${elementName} - using server-processed models (player.js bundle)`);
|
|
46
|
-
}
|
|
46
|
+
// A package without one, such as a legacy `@pie-element/protractor`,
|
|
47
|
+
// renders the model it is given, as `<pie-player>` passed it through.
|
|
48
|
+
logger.debug(`[findPieController] ℹ️ No controller for ${elementName}; its model is used as given`);
|
|
47
49
|
}
|
|
48
50
|
else {
|
|
49
51
|
logger.debug(`[findPieController] ✅ Controller found for ${elementName} with functions:`, Object.keys(controller));
|
|
@@ -85,7 +87,7 @@ export async function scorePieItem(config, sessionData, options = {}) {
|
|
|
85
87
|
logger.debug("found pieEl %O for model id %s", pieEl, model.id);
|
|
86
88
|
const session = findOrAddSession(sessionData, model.id, model.element);
|
|
87
89
|
if (pieEl) {
|
|
88
|
-
const controller = findPieController(pieEl.localName);
|
|
90
|
+
const controller = findPieController(pieEl.localName, options.bundleType);
|
|
89
91
|
if (controller?.outcome) {
|
|
90
92
|
return {
|
|
91
93
|
...session,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Final session commit at a player seam.
|
|
3
|
+
*
|
|
4
|
+
* A delivery element coalesces its `session-changed` dispatch, so a response the
|
|
5
|
+
* learner finished entering can still be pending when the player is about to
|
|
6
|
+
* discard the element. Losing it produces no event, which leaves a host with
|
|
7
|
+
* nothing to detect.
|
|
8
|
+
*
|
|
9
|
+
* Reach, per seam:
|
|
10
|
+
*
|
|
11
|
+
* - `navigate` and `page-hidden` run while the elements are still attached, so
|
|
12
|
+
* the event reaches a `document`-level listener. Quiz Engine listens on
|
|
13
|
+
* `document`.
|
|
14
|
+
* - `teardown` runs from the player's own destroy, which a custom element only
|
|
15
|
+
* learns about in `disconnectedCallback` - after removal. The event reaches
|
|
16
|
+
* the player's own backend save and any listener bound directly to the player
|
|
17
|
+
* element, and it does not reach `document`. An SPA host that unmounts the
|
|
18
|
+
* player is served by `commitPendingSessions()` on the player element, called
|
|
19
|
+
* before the unmount.
|
|
20
|
+
* - Ahead of all three, `flushPendingSessionNotifications` runs when focus
|
|
21
|
+
* leaves a player, so the event lands before the host acts on the click or
|
|
22
|
+
* key that moved it, and carries no reason.
|
|
23
|
+
*
|
|
24
|
+
* Two paths, per element:
|
|
25
|
+
*
|
|
26
|
+
* - An element that owns its notification exposes `commitPendingSession()`, so
|
|
27
|
+
* it dispatches its own event with its own `complete` semantics. The sweep
|
|
28
|
+
* marks that event with `detail.sessionCommitReason`, which the element
|
|
29
|
+
* cannot do for itself and which every guard in a `session-changed`'s way
|
|
30
|
+
* keys on.
|
|
31
|
+
* - An older element gets a synthesized `session-changed` built from its
|
|
32
|
+
* `session` getter. Those elements write the session synchronously and defer
|
|
33
|
+
* only the dispatch, so the read is never older than what the host was last
|
|
34
|
+
* told.
|
|
35
|
+
*
|
|
36
|
+
* Duplicated deliberately into `pie-player-components` and `pie-api-components`,
|
|
37
|
+
* which cannot depend on this package. Changes belong here first; the copies are
|
|
38
|
+
* `src/utils/session-commit.ts` and `src/session-commit.ts` respectively.
|
|
39
|
+
*/
|
|
40
|
+
import type { PieLogger } from "./logger.js";
|
|
41
|
+
/**
|
|
42
|
+
* Commit hook a delivery element installs when it owns a deferred
|
|
43
|
+
* `session-changed`. Owned by `@pie-element/shared-player-events`
|
|
44
|
+
* (`createSessionNotifier`); the name is duplicated rather than imported so the
|
|
45
|
+
* players carry no dependency on an element-repo package.
|
|
46
|
+
*/
|
|
47
|
+
export declare const SESSION_COMMIT_METHOD = "commitPendingSession";
|
|
48
|
+
export type SessionCommitReason = "teardown" | "navigate" | "page-hidden";
|
|
49
|
+
export interface CommitPendingSessionsOptions {
|
|
50
|
+
/** Recorded on a synthesized event as `detail.sessionCommitReason`. */
|
|
51
|
+
reason?: SessionCommitReason;
|
|
52
|
+
logger?: PieLogger;
|
|
53
|
+
}
|
|
54
|
+
export interface CommitPendingSessionsResult {
|
|
55
|
+
/** Elements that committed their own pending notification. */
|
|
56
|
+
committed: number;
|
|
57
|
+
/** Elements for which the player synthesized a `session-changed`. */
|
|
58
|
+
synthesized: number;
|
|
59
|
+
/** Candidate elements with nothing new to commit. */
|
|
60
|
+
skipped: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Record an element's session as one the host has been told about.
|
|
64
|
+
*
|
|
65
|
+
* A player calls this from its `session-changed` listener, which is what makes
|
|
66
|
+
* the commit's discriminant "has anything happened since the host last heard"
|
|
67
|
+
* rather than a guess about an element's session schema.
|
|
68
|
+
*/
|
|
69
|
+
export declare function noteSessionObserved(target: EventTarget | null | undefined): void;
|
|
70
|
+
/**
|
|
71
|
+
* Seed the baseline for every delivery element in a subtree.
|
|
72
|
+
*
|
|
73
|
+
* A player calls this once its elements hold their loaded sessions, so a
|
|
74
|
+
* restored response the learner has not touched is never announced as a change.
|
|
75
|
+
*/
|
|
76
|
+
export declare function noteSessionBaseline(root: ParentNode | null | undefined): void;
|
|
77
|
+
export declare function commitPendingSessions(root: ParentNode | null | undefined, options?: CommitPendingSessionsOptions): CommitPendingSessionsResult;
|
|
78
|
+
/**
|
|
79
|
+
* Deliver every pending `session-changed` now, as an ordinary announcement.
|
|
80
|
+
*
|
|
81
|
+
* The flush a player runs when focus leaves it, ahead of whatever the learner
|
|
82
|
+
* is moving to, so the host hears the response while the item it belongs to is
|
|
83
|
+
* still current. Same two paths and the same discriminant as the commit, with
|
|
84
|
+
* no `sessionCommitReason`: an adopted element's event is exactly what its
|
|
85
|
+
* debounce would have delivered, only earlier, and the next seam finds nothing
|
|
86
|
+
* pending.
|
|
87
|
+
*/
|
|
88
|
+
export declare function flushPendingSessionNotifications(root: ParentNode | null | undefined, options?: {
|
|
89
|
+
logger?: PieLogger;
|
|
90
|
+
}): CommitPendingSessionsResult;
|
|
91
|
+
export interface BindPageLifecycleCommitOptions {
|
|
92
|
+
/**
|
|
93
|
+
* The subtree to commit, re-evaluated on every transition: a player's
|
|
94
|
+
* container is replaced during its lifetime.
|
|
95
|
+
*/
|
|
96
|
+
root: () => ParentNode | null | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Runs after the synchronous commit, for a player that owns a backend save.
|
|
99
|
+
* A hidden document only performs synchronous work dependably, so a save
|
|
100
|
+
* started here needs `keepalive`.
|
|
101
|
+
*/
|
|
102
|
+
onHidden?: (reason: SessionCommitReason) => void;
|
|
103
|
+
logger?: PieLogger;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Commit on the page going away rather than on the player being removed from it.
|
|
107
|
+
*
|
|
108
|
+
* `visibilitychange` to `hidden` is the primary signal: it is the last event a
|
|
109
|
+
* mobile browser reliably delivers before freezing or discarding a page.
|
|
110
|
+
* `pagehide` backs it up for same-document navigation and back/forward cache
|
|
111
|
+
* entry, and for iOS Safari, which can fire it without a preceding
|
|
112
|
+
* `visibilitychange`. Both fire on an ordinary navigation; the per-element
|
|
113
|
+
* baseline makes the second a no-op, and input arriving between them is still
|
|
114
|
+
* committed - which a "commit once per hidden transition" guard would drop.
|
|
115
|
+
* `beforeunload` is not used: it is unreliable on mobile and costs the
|
|
116
|
+
* back/forward cache.
|
|
117
|
+
*
|
|
118
|
+
* Returns an unbind function. Safe to call outside a browser, where it binds
|
|
119
|
+
* nothing.
|
|
120
|
+
*/
|
|
121
|
+
export declare function bindPageLifecycleCommit(options: BindPageLifecycleCommitOptions): () => void;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Final session commit at a player seam.
|
|
3
|
+
*
|
|
4
|
+
* A delivery element coalesces its `session-changed` dispatch, so a response the
|
|
5
|
+
* learner finished entering can still be pending when the player is about to
|
|
6
|
+
* discard the element. Losing it produces no event, which leaves a host with
|
|
7
|
+
* nothing to detect.
|
|
8
|
+
*
|
|
9
|
+
* Reach, per seam:
|
|
10
|
+
*
|
|
11
|
+
* - `navigate` and `page-hidden` run while the elements are still attached, so
|
|
12
|
+
* the event reaches a `document`-level listener. Quiz Engine listens on
|
|
13
|
+
* `document`.
|
|
14
|
+
* - `teardown` runs from the player's own destroy, which a custom element only
|
|
15
|
+
* learns about in `disconnectedCallback` - after removal. The event reaches
|
|
16
|
+
* the player's own backend save and any listener bound directly to the player
|
|
17
|
+
* element, and it does not reach `document`. An SPA host that unmounts the
|
|
18
|
+
* player is served by `commitPendingSessions()` on the player element, called
|
|
19
|
+
* before the unmount.
|
|
20
|
+
* - Ahead of all three, `flushPendingSessionNotifications` runs when focus
|
|
21
|
+
* leaves a player, so the event lands before the host acts on the click or
|
|
22
|
+
* key that moved it, and carries no reason.
|
|
23
|
+
*
|
|
24
|
+
* Two paths, per element:
|
|
25
|
+
*
|
|
26
|
+
* - An element that owns its notification exposes `commitPendingSession()`, so
|
|
27
|
+
* it dispatches its own event with its own `complete` semantics. The sweep
|
|
28
|
+
* marks that event with `detail.sessionCommitReason`, which the element
|
|
29
|
+
* cannot do for itself and which every guard in a `session-changed`'s way
|
|
30
|
+
* keys on.
|
|
31
|
+
* - An older element gets a synthesized `session-changed` built from its
|
|
32
|
+
* `session` getter. Those elements write the session synchronously and defer
|
|
33
|
+
* only the dispatch, so the read is never older than what the host was last
|
|
34
|
+
* told.
|
|
35
|
+
*
|
|
36
|
+
* Duplicated deliberately into `pie-player-components` and `pie-api-components`,
|
|
37
|
+
* which cannot depend on this package. Changes belong here first; the copies are
|
|
38
|
+
* `src/utils/session-commit.ts` and `src/session-commit.ts` respectively.
|
|
39
|
+
*/
|
|
40
|
+
import { hasLearnerResponse } from "./item-session-contract.js";
|
|
41
|
+
/**
|
|
42
|
+
* Commit hook a delivery element installs when it owns a deferred
|
|
43
|
+
* `session-changed`. Owned by `@pie-element/shared-player-events`
|
|
44
|
+
* (`createSessionNotifier`); the name is duplicated rather than imported so the
|
|
45
|
+
* players carry no dependency on an element-repo package.
|
|
46
|
+
*/
|
|
47
|
+
export const SESSION_COMMIT_METHOD = "commitPendingSession";
|
|
48
|
+
/**
|
|
49
|
+
* Where the last-observed session signature is recorded, on the element itself.
|
|
50
|
+
*
|
|
51
|
+
* On the element rather than in a module-scope map for two reasons: a nested
|
|
52
|
+
* player stack loads two copies of this module from different bundles, and
|
|
53
|
+
* module state would then let each layer announce the same commit; and a map
|
|
54
|
+
* keyed by element records what the *sweep* last emitted, which suppresses a
|
|
55
|
+
* later commit of a value the sweep emitted once before (answer A, hide, answer
|
|
56
|
+
* B, answer A again, navigate mid-debounce - the host keeps B).
|
|
57
|
+
*
|
|
58
|
+
* `Symbol.for` so the two bundles resolve the same key.
|
|
59
|
+
*/
|
|
60
|
+
const OBSERVED_SIGNATURE = Symbol.for("pie.sessionCommit.observedSignature");
|
|
61
|
+
function isRecord(value) {
|
|
62
|
+
return !!value && typeof value === "object";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A PIE delivery element carries both halves of the element contract: a
|
|
66
|
+
* `session` accessor and a `model` setter. Requiring both keeps the sweep off
|
|
67
|
+
* player chrome and tool custom elements, and an element that has not been
|
|
68
|
+
* upgraded yet has neither.
|
|
69
|
+
*/
|
|
70
|
+
function isPieDeliveryElement(element) {
|
|
71
|
+
if (!element.tagName.includes("-"))
|
|
72
|
+
return false;
|
|
73
|
+
return "session" in element && "model" in element;
|
|
74
|
+
}
|
|
75
|
+
function shadowRootOf(node) {
|
|
76
|
+
const candidate = node
|
|
77
|
+
?.shadowRoot;
|
|
78
|
+
return candidate ?? null;
|
|
79
|
+
}
|
|
80
|
+
function assignedElementsOf(element) {
|
|
81
|
+
const slot = element;
|
|
82
|
+
if (typeof slot.assignedElements !== "function")
|
|
83
|
+
return [];
|
|
84
|
+
try {
|
|
85
|
+
return slot.assignedElements({ flatten: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Walks the flattened tree: `children`, then open shadow roots, then the
|
|
93
|
+
* elements assigned to each `<slot>`.
|
|
94
|
+
*
|
|
95
|
+
* `querySelectorAll("*")` sees none of the last two, and both are load-bearing
|
|
96
|
+
* here. The section player mounts each item inside `<pie-item-shell>`'s shadow
|
|
97
|
+
* root, and projects its item pane through a slot on the assessment toolkit -
|
|
98
|
+
* a walk that stops at `<slot>` finds every delivery element in a section
|
|
99
|
+
* unreachable.
|
|
100
|
+
*/
|
|
101
|
+
function collectCandidates(root, seen, out) {
|
|
102
|
+
const rootShadow = shadowRootOf(root);
|
|
103
|
+
if (rootShadow)
|
|
104
|
+
collectCandidates(rootShadow, seen, out);
|
|
105
|
+
const visit = (element) => {
|
|
106
|
+
if (seen.has(element))
|
|
107
|
+
return;
|
|
108
|
+
seen.add(element);
|
|
109
|
+
if (isPieDeliveryElement(element))
|
|
110
|
+
out.push(element);
|
|
111
|
+
collectCandidates(element, seen, out);
|
|
112
|
+
};
|
|
113
|
+
for (const assigned of assignedElementsOf(root))
|
|
114
|
+
visit(assigned);
|
|
115
|
+
const children = root.children;
|
|
116
|
+
if (!children)
|
|
117
|
+
return;
|
|
118
|
+
for (const element of Array.from(children))
|
|
119
|
+
visit(element);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Duck-typed rather than `instanceof Element`: the legacy players' test
|
|
123
|
+
* environment is Stencil's mock DOM, where a mounted element is not an instance
|
|
124
|
+
* of the global `Element`, and a check that silently fails there fails the same
|
|
125
|
+
* way against any other DOM implementation a host brings.
|
|
126
|
+
*/
|
|
127
|
+
function isElementLike(node) {
|
|
128
|
+
return (!!node &&
|
|
129
|
+
typeof node === "object" &&
|
|
130
|
+
typeof node.tagName === "string");
|
|
131
|
+
}
|
|
132
|
+
function candidatesIn(root) {
|
|
133
|
+
const candidates = [];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
if (isElementLike(root) && isPieDeliveryElement(root)) {
|
|
136
|
+
seen.add(root);
|
|
137
|
+
candidates.push(root);
|
|
138
|
+
}
|
|
139
|
+
collectCandidates(root, seen, candidates);
|
|
140
|
+
return candidates;
|
|
141
|
+
}
|
|
142
|
+
function readSession(element) {
|
|
143
|
+
try {
|
|
144
|
+
return element.session;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// A getter that throws is not a session the player can commit.
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function signatureOf(session) {
|
|
152
|
+
try {
|
|
153
|
+
const json = JSON.stringify(session);
|
|
154
|
+
// `JSON.stringify(undefined)` is `undefined`, which would be written as a
|
|
155
|
+
// signature and then read back as "no baseline".
|
|
156
|
+
return typeof json === "string" ? json : null;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function readObservedSignature(element) {
|
|
163
|
+
const recorded = element[OBSERVED_SIGNATURE];
|
|
164
|
+
return typeof recorded === "string" ? recorded : undefined;
|
|
165
|
+
}
|
|
166
|
+
function writeObservedSignature(element, signature) {
|
|
167
|
+
try {
|
|
168
|
+
Object.defineProperty(element, OBSERVED_SIGNATURE, {
|
|
169
|
+
value: signature,
|
|
170
|
+
configurable: true,
|
|
171
|
+
enumerable: false,
|
|
172
|
+
writable: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// A frozen element cannot be marked. It then commits again on the next
|
|
177
|
+
// seam, which a player's own session signature check absorbs.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Record an element's session as one the host has been told about.
|
|
182
|
+
*
|
|
183
|
+
* A player calls this from its `session-changed` listener, which is what makes
|
|
184
|
+
* the commit's discriminant "has anything happened since the host last heard"
|
|
185
|
+
* rather than a guess about an element's session schema.
|
|
186
|
+
*/
|
|
187
|
+
export function noteSessionObserved(target) {
|
|
188
|
+
if (!isElementLike(target))
|
|
189
|
+
return;
|
|
190
|
+
const signature = signatureOf(readSession(target));
|
|
191
|
+
if (signature === null)
|
|
192
|
+
return;
|
|
193
|
+
writeObservedSignature(target, signature);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Seed the baseline for every delivery element in a subtree.
|
|
197
|
+
*
|
|
198
|
+
* A player calls this once its elements hold their loaded sessions, so a
|
|
199
|
+
* restored response the learner has not touched is never announced as a change.
|
|
200
|
+
*/
|
|
201
|
+
export function noteSessionBaseline(root) {
|
|
202
|
+
if (!root)
|
|
203
|
+
return;
|
|
204
|
+
for (const element of candidatesIn(root)) {
|
|
205
|
+
const signature = signatureOf(readSession(element));
|
|
206
|
+
if (signature !== null)
|
|
207
|
+
writeObservedSignature(element, signature);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Mark an element-owned commit's own event as a commit, for the length of the
|
|
212
|
+
* sweep.
|
|
213
|
+
*
|
|
214
|
+
* `commitPendingSession()` lets the element dispatch its own `session-changed`,
|
|
215
|
+
* with its own `complete` semantics, and that event carries no commit marker.
|
|
216
|
+
* Every guard a player puts in a `session-changed`'s way keys on
|
|
217
|
+
* `detail.sessionCommitReason` to let a commit through: the Stencil player's
|
|
218
|
+
* model-set blocker, this project's renderer dedupe, the section player's
|
|
219
|
+
* cross-shell dedupe, `<pie-api-player>`'s save-now decision. Unmarked, an
|
|
220
|
+
* element-owned commit is exactly the event those guards drop - and the
|
|
221
|
+
* elements that adopted `createSessionNotifier` are the ones taking that path,
|
|
222
|
+
* so the exemption applied to nothing.
|
|
223
|
+
*
|
|
224
|
+
* A capture listener on the sweep root runs before the target's own listeners
|
|
225
|
+
* and before every bubble listener at or above the root, which is where all
|
|
226
|
+
* four guards sit. Only the absent key is added; a synthesized event already
|
|
227
|
+
* carries its own.
|
|
228
|
+
*/
|
|
229
|
+
function markCommitsDuring(root, reason, sweep) {
|
|
230
|
+
const target = root;
|
|
231
|
+
if (typeof target.addEventListener !== "function")
|
|
232
|
+
return sweep();
|
|
233
|
+
const stamp = (event) => {
|
|
234
|
+
const detail = event.detail;
|
|
235
|
+
if (!isRecord(detail) || "sessionCommitReason" in detail)
|
|
236
|
+
return;
|
|
237
|
+
try {
|
|
238
|
+
detail.sessionCommitReason = reason;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// A frozen detail keeps its own contract; the commit still dispatches.
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
target.addEventListener("session-changed", stamp, true);
|
|
245
|
+
try {
|
|
246
|
+
return sweep();
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
target.removeEventListener("session-changed", stamp, true);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export function commitPendingSessions(root, options = {}) {
|
|
253
|
+
const result = {
|
|
254
|
+
committed: 0,
|
|
255
|
+
synthesized: 0,
|
|
256
|
+
skipped: 0,
|
|
257
|
+
};
|
|
258
|
+
if (!root)
|
|
259
|
+
return result;
|
|
260
|
+
const reason = options.reason ?? "teardown";
|
|
261
|
+
return markCommitsDuring(root, reason, () => sweepPendingSessions(root, reason, options.logger, result));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* `reason` is null for a flush, whose events are ordinary announcements.
|
|
265
|
+
*/
|
|
266
|
+
function sweepPendingSessions(root, reason, logger, result) {
|
|
267
|
+
const caller = reason ? "commitPendingSessions" : "flushPendingSessionNotifications";
|
|
268
|
+
for (const element of candidatesIn(root)) {
|
|
269
|
+
const session = readSession(element);
|
|
270
|
+
if (!isRecord(session)) {
|
|
271
|
+
result.skipped += 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const signature = signatureOf(session);
|
|
275
|
+
if (signature === null) {
|
|
276
|
+
result.skipped += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const observed = readObservedSignature(element);
|
|
280
|
+
if (observed === signature) {
|
|
281
|
+
// The host already has this. Nothing is pending, whichever path the
|
|
282
|
+
// element would have taken.
|
|
283
|
+
result.skipped += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (observed === undefined && !hasLearnerResponse(session)) {
|
|
287
|
+
// No baseline, so the player never saw this element load - the section
|
|
288
|
+
// player's shells and the legacy Stencil stack both have paths where
|
|
289
|
+
// that happens. Announcing an untouched element's identity-only session
|
|
290
|
+
// makes a host that re-renders on `session-changed` cancel work it was
|
|
291
|
+
// in the middle of: a config swap re-pushed the host's own config and
|
|
292
|
+
// discarded the incoming item.
|
|
293
|
+
result.skipped += 1;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const commit = element[SESSION_COMMIT_METHOD];
|
|
297
|
+
if (typeof commit === "function") {
|
|
298
|
+
// An element's own commit is a no-op when nothing is pending, and its
|
|
299
|
+
// session can still differ from what the host heard: a controller
|
|
300
|
+
// writing into the session, or an element path that stores a value
|
|
301
|
+
// without notifying. Counting the call as the announcement recorded a
|
|
302
|
+
// response the host never received and every later seam then skipped
|
|
303
|
+
// it, so what the element dispatches is what counts. `flush()` is
|
|
304
|
+
// synchronous in every implementation of this contract; one that
|
|
305
|
+
// deferred would be announced twice, which a player's signature check
|
|
306
|
+
// absorbs.
|
|
307
|
+
let dispatched = false;
|
|
308
|
+
const witness = () => {
|
|
309
|
+
dispatched = true;
|
|
310
|
+
};
|
|
311
|
+
element.addEventListener("session-changed", witness, true);
|
|
312
|
+
let failed = false;
|
|
313
|
+
try {
|
|
314
|
+
commit.call(element);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
failed = true;
|
|
318
|
+
logger?.warn(`[${caller}] ${element.tagName.toLowerCase()} failed to commit its pending session`, error);
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
element.removeEventListener("session-changed", witness, true);
|
|
322
|
+
}
|
|
323
|
+
if (failed) {
|
|
324
|
+
result.skipped += 1;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (dispatched) {
|
|
328
|
+
writeObservedSignature(element, signature);
|
|
329
|
+
result.committed += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// Nothing was pending, so fall through and announce the session the
|
|
333
|
+
// element holds.
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
element.dispatchEvent(new CustomEvent("session-changed", {
|
|
337
|
+
bubbles: true,
|
|
338
|
+
composed: true,
|
|
339
|
+
detail: {
|
|
340
|
+
component: element.tagName.toLowerCase(),
|
|
341
|
+
session: JSON.parse(signature),
|
|
342
|
+
...(reason ? { sessionCommitReason: reason } : {}),
|
|
343
|
+
},
|
|
344
|
+
}));
|
|
345
|
+
writeObservedSignature(element, signature);
|
|
346
|
+
result.synthesized += 1;
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
logger?.warn(`[${caller}] ${element.tagName.toLowerCase()} session commit dispatch failed`, error);
|
|
350
|
+
result.skipped += 1;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Deliver every pending `session-changed` now, as an ordinary announcement.
|
|
357
|
+
*
|
|
358
|
+
* The flush a player runs when focus leaves it, ahead of whatever the learner
|
|
359
|
+
* is moving to, so the host hears the response while the item it belongs to is
|
|
360
|
+
* still current. Same two paths and the same discriminant as the commit, with
|
|
361
|
+
* no `sessionCommitReason`: an adopted element's event is exactly what its
|
|
362
|
+
* debounce would have delivered, only earlier, and the next seam finds nothing
|
|
363
|
+
* pending.
|
|
364
|
+
*/
|
|
365
|
+
export function flushPendingSessionNotifications(root, options = {}) {
|
|
366
|
+
const result = {
|
|
367
|
+
committed: 0,
|
|
368
|
+
synthesized: 0,
|
|
369
|
+
skipped: 0,
|
|
370
|
+
};
|
|
371
|
+
if (!root)
|
|
372
|
+
return result;
|
|
373
|
+
return sweepPendingSessions(root, null, options.logger, result);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Commit on the page going away rather than on the player being removed from it.
|
|
377
|
+
*
|
|
378
|
+
* `visibilitychange` to `hidden` is the primary signal: it is the last event a
|
|
379
|
+
* mobile browser reliably delivers before freezing or discarding a page.
|
|
380
|
+
* `pagehide` backs it up for same-document navigation and back/forward cache
|
|
381
|
+
* entry, and for iOS Safari, which can fire it without a preceding
|
|
382
|
+
* `visibilitychange`. Both fire on an ordinary navigation; the per-element
|
|
383
|
+
* baseline makes the second a no-op, and input arriving between them is still
|
|
384
|
+
* committed - which a "commit once per hidden transition" guard would drop.
|
|
385
|
+
* `beforeunload` is not used: it is unreliable on mobile and costs the
|
|
386
|
+
* back/forward cache.
|
|
387
|
+
*
|
|
388
|
+
* Returns an unbind function. Safe to call outside a browser, where it binds
|
|
389
|
+
* nothing.
|
|
390
|
+
*/
|
|
391
|
+
export function bindPageLifecycleCommit(options) {
|
|
392
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
393
|
+
return () => { };
|
|
394
|
+
}
|
|
395
|
+
const commit = (reason) => {
|
|
396
|
+
commitPendingSessions(options.root(), {
|
|
397
|
+
reason,
|
|
398
|
+
logger: options.logger,
|
|
399
|
+
});
|
|
400
|
+
try {
|
|
401
|
+
options.onHidden?.(reason);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
options.logger?.warn("[bindPageLifecycleCommit] onHidden failed", error);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const handleVisibilityChange = () => {
|
|
408
|
+
if (document.visibilityState === "hidden")
|
|
409
|
+
commit("page-hidden");
|
|
410
|
+
};
|
|
411
|
+
const handlePageHide = () => {
|
|
412
|
+
commit("page-hidden");
|
|
413
|
+
};
|
|
414
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
415
|
+
window.addEventListener("pagehide", handlePageHide);
|
|
416
|
+
return () => {
|
|
417
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
418
|
+
window.removeEventListener("pagehide", handlePageHide);
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-local snapshot of a committed session.
|
|
3
|
+
*
|
|
4
|
+
* A crash or an OS kill fires no lifecycle event, so the only state that
|
|
5
|
+
* survives it is state already written. The snapshot writes each committed
|
|
6
|
+
* session to device storage and offers it back on the next load.
|
|
7
|
+
*
|
|
8
|
+
* It is offered, never applied. School devices are shared, and a player cannot
|
|
9
|
+
* tell a legitimate recovery from a previous student's draft, so the host
|
|
10
|
+
* decides.
|
|
11
|
+
*
|
|
12
|
+
* `sessionStorage` is the default, matching the annotation toolbar's existing
|
|
13
|
+
* use of it for learner annotations: it is tab-scoped and cleared when the tab
|
|
14
|
+
* closes, so a response does not outlive the sitting, and browser session
|
|
15
|
+
* restore brings it back after a crash. A host that wants recovery across a
|
|
16
|
+
* full browser restart injects a `localStorage`-backed store and owns the
|
|
17
|
+
* retention consequences.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionSnapshotStore {
|
|
20
|
+
read(key: string): string | null;
|
|
21
|
+
write(key: string, value: string): void;
|
|
22
|
+
clear(key: string): void;
|
|
23
|
+
}
|
|
24
|
+
export type SessionSnapshotConfig = boolean | {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
/** Defaults to `sessionStorage`. */
|
|
27
|
+
store?: SessionSnapshotStore;
|
|
28
|
+
/** Defaults to a key derived from the delivery identity. */
|
|
29
|
+
key?: string;
|
|
30
|
+
};
|
|
31
|
+
export interface SessionSnapshotRecord {
|
|
32
|
+
key: string;
|
|
33
|
+
session: unknown;
|
|
34
|
+
/** `Date.now()` at the time of the write. */
|
|
35
|
+
timestamp: number;
|
|
36
|
+
}
|
|
37
|
+
export interface SessionSnapshot {
|
|
38
|
+
readonly key: string;
|
|
39
|
+
write(session: unknown): void;
|
|
40
|
+
read(): SessionSnapshotRecord | null;
|
|
41
|
+
clear(): void;
|
|
42
|
+
}
|
|
43
|
+
export type SessionSnapshotIdentity = {
|
|
44
|
+
itemId?: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
assignmentId?: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Whether an identity names one sitting.
|
|
50
|
+
*
|
|
51
|
+
* `sessionId` is the only field that distinguishes one learner's attempt from
|
|
52
|
+
* another's. Keyed on the item alone, a shared device offers the previous
|
|
53
|
+
* student's draft to the next one - and the offer is itself a disclosure, before
|
|
54
|
+
* the host ever decides whether to apply it. So without a `sessionId` there is
|
|
55
|
+
* no snapshot, and a host driving the player by props alone opts in by passing
|
|
56
|
+
* an explicit `key`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isSnapshotIdentitySpecific(identity: SessionSnapshotIdentity): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Key built from the delivery identity, so a snapshot is only offered back to
|
|
61
|
+
* the sitting that wrote it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function sessionSnapshotKey(identity: SessionSnapshotIdentity): string;
|
|
64
|
+
/**
|
|
65
|
+
* `sessionStorage`-backed store. Every access is guarded: private mode and
|
|
66
|
+
* blocked site data make the accessor throw, and a player must keep working
|
|
67
|
+
* without a snapshot rather than fail.
|
|
68
|
+
*/
|
|
69
|
+
export declare function sessionStorageSnapshotStore(): SessionSnapshotStore;
|
|
70
|
+
export declare function isSessionSnapshotEnabled(config: SessionSnapshotConfig | null | undefined): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Returns `null` when the host has not enabled a snapshot, and when the
|
|
73
|
+
* identity does not name one sitting. PIE writes learner responses to the
|
|
74
|
+
* device only on an explicit opt-in, and only under a key no other learner
|
|
75
|
+
* shares.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createSessionSnapshot(args: {
|
|
78
|
+
config: SessionSnapshotConfig | null | undefined;
|
|
79
|
+
identity: SessionSnapshotIdentity;
|
|
80
|
+
}): SessionSnapshot | null;
|