@pie-players/pie-players-shared 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -8,6 +8,7 @@
8
8
  */
9
9
  /// <reference path="../shims.d.ts" />
10
10
  import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
11
+ import { pageMathAssetRoot } from "./math-assets.js";
11
12
  const GLOBAL_KEY = "@pie-lib/math-rendering";
12
13
  const GLOBAL_DLL_KEY = "_dll_pie_lib__math_rendering";
13
14
  // Every copy of @pie-lib/math-rendering on a page typesets through the MathJax
@@ -15,6 +16,52 @@ const GLOBAL_DLL_KEY = "_dll_pie_lib__math_rendering";
15
16
  const SHARED_INSTANCE_KEY = "@pie-lib/math-rendering@2";
16
17
  let initPromise = null;
17
18
  const logger = createPieLogger("math-rendering", () => isGlobalDebugEnabled());
19
+ /**
20
+ * The speech-rule-engine version the default renderer bundles, whose rules it
21
+ * loads from `speech-rule-engine@<version>/lib/mathmaps` under an npm root. A
22
+ * test pins it to the module.
23
+ * @internal
24
+ */
25
+ export const DEFAULT_RENDERER_SRE_VERSION = "4.1.2";
26
+ const SRE_FEATURE_KEY = "SREfeature";
27
+ const pageNamesSpeechRules = () => {
28
+ const feature = window[SRE_FEATURE_KEY];
29
+ if (feature && typeof feature === "object" && feature.json)
30
+ return true;
31
+ return Array.from(document.querySelectorAll('script[type="text/x-sre-config"]')).some((block) => {
32
+ try {
33
+ return !!JSON.parse(block.textContent || "")?.json;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ });
39
+ };
40
+ /**
41
+ * Points the module's speech-rule-engine at the rules under `root` and returns
42
+ * the undo. SRE reads `SREfeature` as it sets up, synchronously while the
43
+ * module evaluates, and its later setups keep the path, so the global is
44
+ * restored afterwards: another copy of SRE on the page, as a legacy bundle
45
+ * carries, keeps the rules of its own version.
46
+ */
47
+ const pointSpeechRulesAt = (root) => {
48
+ if (!root || typeof document === "undefined" || pageNamesSpeechRules()) {
49
+ return () => { };
50
+ }
51
+ const host = window;
52
+ const had = Object.hasOwn(host, SRE_FEATURE_KEY);
53
+ const previous = host[SRE_FEATURE_KEY];
54
+ host[SRE_FEATURE_KEY] = {
55
+ ...(previous && typeof previous === "object" ? previous : {}),
56
+ json: `${root.replace(/\/+$/, "")}/speech-rule-engine@${DEFAULT_RENDERER_SRE_VERSION}/lib/mathmaps`,
57
+ };
58
+ return () => {
59
+ if (had)
60
+ host[SRE_FEATURE_KEY] = previous;
61
+ else
62
+ delete host[SRE_FEATURE_KEY];
63
+ };
64
+ };
18
65
  /**
19
66
  * The page's renderer: the one `initializeMathRendering` installed, or a
20
67
  * host's. IIFE elements render with it, and so do ESM elements when the page
@@ -66,6 +113,8 @@ const createSharedInstance = (renderer) => {
66
113
  * - window["@pie-lib/math-rendering"] (standard key)
67
114
  * - window["_dll_pie_lib__math_rendering"] (SystemJS/DLL key for IIFE bundles)
68
115
  *
116
+ * The first call installs the renderer for the page, so its `options` apply.
117
+ *
69
118
  * @example
70
119
  * ```typescript
71
120
  * // Default MathJax
@@ -76,7 +125,7 @@ const createSharedInstance = (renderer) => {
76
125
  * await initializeMathRendering();
77
126
  * ```
78
127
  */
79
- export async function initializeMathRendering() {
128
+ export async function initializeMathRendering(options = {}) {
80
129
  // Only run in browser
81
130
  if (typeof window === "undefined") {
82
131
  return;
@@ -90,6 +139,7 @@ export async function initializeMathRendering() {
90
139
  return;
91
140
  }
92
141
  initPromise = (async () => {
142
+ const restoreSpeechRules = pointSpeechRulesAt(pageMathAssetRoot() ?? options.assetRoot);
93
143
  try {
94
144
  // The package has no exports map, so the file is named in full for
95
145
  // resolvers that do not complete a directory, such as webpack's
@@ -112,6 +162,9 @@ export async function initializeMathRendering() {
112
162
  logger.error("Failed to initialize math rendering:", error);
113
163
  throw error;
114
164
  }
165
+ finally {
166
+ restoreSpeechRules();
167
+ }
115
168
  })();
116
169
  try {
117
170
  await initPromise;
@@ -557,6 +557,10 @@ export interface ToolParameterMap {
557
557
  pitch?: number;
558
558
  volume?: number;
559
559
  };
560
+ /** `zoom` is the lens's starting magnification, 2 meaning twice the size. */
561
+ magnification: {
562
+ zoom?: number;
563
+ };
560
564
  }
561
565
  /** The parameter shape of tool `K`: its {@link ToolParameterMap} entry, else open. */
562
566
  export type ToolParametersFor<K extends string> = K extends keyof ToolParameterMap ? ToolParameterMap[K] : Record<string, unknown>;
@@ -582,6 +586,14 @@ export interface Env {
582
586
  mode: PlayerMode;
583
587
  role: PlayerRole;
584
588
  partialScoring?: boolean;
589
+ /**
590
+ * BCP 47 tag of the language the item's content is written in, when the
591
+ * delivery knows it. The player reflects it onto the content subtree as
592
+ * `lang`/`dir`; a host that emits nothing leaves the content unstamped.
593
+ * The interface locale never travels here — it is the player's `locale`
594
+ * attribute and the `pie.interfaceLocale` context.
595
+ */
596
+ locale?: string;
585
597
  }
586
598
  /** The `detail` of the `player-error` event `pie-item-player` dispatches. */
587
599
  export interface PieItemPlayerErrorDetail {
@@ -24,4 +24,16 @@ type FocusTrapOptions = {
24
24
  * and restores prior focus when the trap is removed.
25
25
  */
26
26
  export declare function createFocusTrap(container: HTMLElement, options?: FocusTrapOptions): () => void;
27
+ /**
28
+ * Returns focus to where it was before a tool took it, for a tool that closes
29
+ * itself: the tool's element leaves the DOM on close, which drops focus to the
30
+ * document body. `capture` runs as the tool takes focus and `restore` as it
31
+ * closes, before its element goes.
32
+ */
33
+ export declare function createFocusReturn(): {
34
+ /** Records the focused element, unless focus is already inside `container`. */
35
+ capture: (container: Node) => void;
36
+ /** Focuses the recorded element, if it is still in the document. */
37
+ restore: () => void;
38
+ };
27
39
  export {};
@@ -44,7 +44,11 @@ export function createFocusTrap(container, options = {}) {
44
44
  const wrap = options.wrap ?? true;
45
45
  const onKeydown = (event) => {
46
46
  if (event.key === "Escape") {
47
- options.onEscape?.();
47
+ if (!options.onEscape)
48
+ return;
49
+ // Claimed, so a page-wide Escape listener does not also act on it.
50
+ event.preventDefault();
51
+ options.onEscape();
48
52
  return;
49
53
  }
50
54
  if (event.key !== "Tab")
@@ -94,3 +98,37 @@ export function createFocusTrap(container, options = {}) {
94
98
  }
95
99
  };
96
100
  }
101
+ /**
102
+ * Returns focus to where it was before a tool took it, for a tool that closes
103
+ * itself: the tool's element leaves the DOM on close, which drops focus to the
104
+ * document body. `capture` runs as the tool takes focus and `restore` as it
105
+ * closes, before its element goes.
106
+ */
107
+ export function createFocusReturn() {
108
+ let opener = null;
109
+ return {
110
+ capture(container) {
111
+ if (typeof document === "undefined")
112
+ return;
113
+ const active = getDeepActiveElement(document);
114
+ if (active && container.contains(active))
115
+ return;
116
+ opener =
117
+ active instanceof HTMLElement && active !== document.body
118
+ ? active
119
+ : null;
120
+ },
121
+ restore() {
122
+ const target = opener;
123
+ opener = null;
124
+ if (!target?.isConnected)
125
+ return;
126
+ try {
127
+ target.focus();
128
+ }
129
+ catch {
130
+ // ignore
131
+ }
132
+ },
133
+ };
134
+ }
@@ -17,6 +17,11 @@ export interface Point {
17
17
  x: number;
18
18
  y: number;
19
19
  }
20
+ /** A box on screen: its top-left corner and its extent, as a `DOMRect` gives them. */
21
+ export interface ScreenBox extends Size {
22
+ left: number;
23
+ top: number;
24
+ }
20
25
  /**
21
26
  * The box `element` resolves its `left`/`top` against: its containing block, or
22
27
  * the viewport when that is the initial containing block.
@@ -77,3 +82,11 @@ export declare function uprightDockCentre(box: Size, strip: Size, degrees: numbe
77
82
  * where a box exceeds its block and centres on the offending axis.
78
83
  */
79
84
  export declare function clampPointWithinBlock(point: Point, box: Size, block: Size, gutter?: number): Point;
85
+ /**
86
+ * The top-left corner, in viewport coordinates, of a control strip docked
87
+ * beside an upright `tool`: centred below it, or above it when only the space
88
+ * above holds the strip, and slid along the tool's edge to stay inside the
89
+ * block. A strip that fits on neither side takes the roomier one; a strip wider
90
+ * than the block centres on it.
91
+ */
92
+ export declare function uprightDockCorner(tool: ScreenBox, strip: Size, block: ScreenBox, gap: number, gutter?: number): Point;
@@ -126,3 +126,26 @@ export function clampPointWithinBlock(point, box, block, gutter = DEFAULT_CONTAI
126
126
  const offset = clampOffsetWithinBlock({ x: point.x - centre.x, y: point.y - centre.y }, box, block, gutter);
127
127
  return { x: centre.x + offset.x, y: centre.y + offset.y };
128
128
  }
129
+ /**
130
+ * The top-left corner, in viewport coordinates, of a control strip docked
131
+ * beside an upright `tool`: centred below it, or above it when only the space
132
+ * above holds the strip, and slid along the tool's edge to stay inside the
133
+ * block. A strip that fits on neither side takes the roomier one; a strip wider
134
+ * than the block centres on it.
135
+ */
136
+ export function uprightDockCorner(tool, strip, block, gap, gutter = DEFAULT_CONTAINMENT_GUTTER) {
137
+ const top = block.top + gutter;
138
+ const bottom = block.top + block.height - gutter;
139
+ const below = tool.top + tool.height + gap;
140
+ const above = tool.top - gap - strip.height;
141
+ const roomBelow = bottom - below;
142
+ const roomAbove = tool.top - gap - top;
143
+ const y = roomBelow >= strip.height || (roomAbove < strip.height && roomBelow >= roomAbove)
144
+ ? below
145
+ : above;
146
+ const travel = block.width - gutter * 2 - strip.width;
147
+ const x = travel <= 0
148
+ ? block.left + (block.width - strip.width) / 2
149
+ : Math.max(block.left + gutter, Math.min(block.left + gutter + travel, tool.left + (tool.width - strip.width) / 2));
150
+ return { x, y };
151
+ }
@@ -53,6 +53,34 @@ export type OverlayPlacementNudge = keyof typeof OVERLAY_PLACEMENT_NUDGES;
53
53
  export type OverlayPlacementAnnouncement = (typeof OVERLAY_PLACEMENT_NUDGES)[OverlayPlacementNudge]["announceKey"] | "toolkit.announce.rotatedTo";
54
54
  /** The tap controls' rotations, the keyboard's two steps each way. */
55
55
  export declare const OVERLAY_PLACEMENT_ROTATIONS: readonly [number, number, 1, 5];
56
+ /**
57
+ * What the tap controls drive: a step for each arrow, and a turn for a tool
58
+ * that rotates. An `OverlayPlacementController` is one; an upright tool that
59
+ * keeps its own position supplies `nudge` alone.
60
+ */
61
+ export interface OverlayTapTarget {
62
+ nudge: (direction: OverlayPlacementNudge) => void;
63
+ rotateBy?: (degrees: number) => void;
64
+ }
65
+ /** A shrink-and-grow pair on the tap controls, labelled by the tool. */
66
+ export interface OverlaySizeControl {
67
+ shrink: {
68
+ glyph: string;
69
+ label: string;
70
+ };
71
+ grow: {
72
+ glyph: string;
73
+ label: string;
74
+ };
75
+ step: (direction: 1 | -1) => void;
76
+ }
77
+ /**
78
+ * Docks an upright tool's tap controls beside it, as `uprightDockCorner`
79
+ * places them. `OverlayPlacementControls` lays the controls out at the tool's
80
+ * top-left corner, so the offset is taken from there. A strip wider than the
81
+ * block wraps onto more rows, so a phone-width pane does not clip it.
82
+ */
83
+ export declare function dockUprightControls(element: HTMLElement, controls: HTMLElement): void;
56
84
  export interface OverlayPlacementOptions {
57
85
  /** The tool's positioned element, which receives the transform. */
58
86
  getElement: () => HTMLElement | undefined;
@@ -4,7 +4,7 @@
4
4
  * its `style.transform`, so a drag, an arrow key and a tap control always agree
5
5
  * about where the tool is.
6
6
  */
7
- import { clampOffsetOverlappingBlock, resolveContainingBlockRect, rotatedExtent, rotationCentreShift, uprightDockCentre, } from "./overlay-containment.js";
7
+ import { clampOffsetOverlappingBlock, DEFAULT_CONTAINMENT_GUTTER, resolveContainingBlockRect, rotatedExtent, rotationCentreShift, uprightDockCentre, uprightDockCorner, } from "./overlay-containment.js";
8
8
  import { createPointerDragController } from "./pointer-drag.js";
9
9
  import { createPointerGesture } from "./pointer-gesture.js";
10
10
  import { createPointerRotateController } from "./pointer-rotate.js";
@@ -60,8 +60,23 @@ const ARROW_KEYS = {
60
60
  };
61
61
  /** Pixels of the tool kept inside its containing block on each axis. */
62
62
  const MIN_VISIBLE = 100;
63
- /** Space between the tool's bottom edge and its tap controls. */
63
+ /** Space between the tool and its tap controls. */
64
64
  const CONTROLS_GAP = 8;
65
+ /**
66
+ * Docks an upright tool's tap controls beside it, as `uprightDockCorner`
67
+ * places them. `OverlayPlacementControls` lays the controls out at the tool's
68
+ * top-left corner, so the offset is taken from there. A strip wider than the
69
+ * block wraps onto more rows, so a phone-width pane does not clip it.
70
+ */
71
+ export function dockUprightControls(element, controls) {
72
+ const block = resolveContainingBlockRect(element);
73
+ if (!block)
74
+ return;
75
+ controls.style.maxWidth = `${Math.max(0, block.width - DEFAULT_CONTAINMENT_GUTTER * 2)}px`;
76
+ const tool = element.getBoundingClientRect();
77
+ const corner = uprightDockCorner(tool, { width: controls.offsetWidth, height: controls.offsetHeight }, block, CONTROLS_GAP);
78
+ controls.style.transform = `translate(${corner.x - tool.left - element.clientLeft}px, ${corner.y - tool.top - element.clientTop}px)`;
79
+ }
65
80
  export function createOverlayPlacement(options) {
66
81
  let placement = { x: 0, y: 0, rotation: 0 };
67
82
  const sizeOf = (element) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-players-shared",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "type": "module",
5
5
  "description": "Shared runtime + UI utilities for PIE players",
6
6
  "license": "MIT",
@@ -106,9 +106,9 @@
106
106
  "dist"
107
107
  ],
108
108
  "dependencies": {
109
- "@pie-element/shared-utils": "0.1.3",
109
+ "@pie-element/shared-utils": "0.1.4",
110
110
  "@pie-lib/math-rendering-module": "6.0.0",
111
- "dompurify": "^3.4.16",
111
+ "dompurify": "^3.4.17",
112
112
  "semver": "^7.8.5"
113
113
  },
114
114
  "scripts": {
@@ -124,8 +124,8 @@
124
124
  "devDependencies": {
125
125
  "@biomejs/biome": "^2.5.15",
126
126
  "@happy-dom/global-registrator": "^20.14.5",
127
- "@pie-element/shared-math-rendering-mathjax": "0.1.4",
128
- "@playwright/test": "^1.64.0",
127
+ "@pie-element/shared-math-rendering-mathjax": "0.1.7",
128
+ "@playwright/test": "^1.64.1",
129
129
  "@types/semver": "^7.8.0",
130
130
  "es-module-shims": "^2.8.4",
131
131
  "esbuild": "^0.28.2",