@pie-players/pie-players-shared 0.3.69 → 0.3.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/i18n/messages/en-US.d.ts +2 -0
  2. package/dist/i18n/messages/en-US.js +2 -0
  3. package/dist/i18n/messages/nl-NL.d.ts +2 -0
  4. package/dist/i18n/messages/nl-NL.js +2 -0
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/loaders/iife-adapter.js +7 -3
  8. package/dist/pie/element-observer.d.ts +59 -0
  9. package/dist/pie/element-observer.js +131 -0
  10. package/dist/pie/index.d.ts +4 -2
  11. package/dist/pie/index.js +7 -2
  12. package/dist/pie/initialization.d.ts +13 -13
  13. package/dist/pie/initialization.js +97 -147
  14. package/dist/pie/initialize-element.d.ts +23 -0
  15. package/dist/pie/initialize-element.js +78 -0
  16. package/dist/pie/instrumentation-event-map.d.ts +1 -0
  17. package/dist/pie/instrumentation-event-map.js +18 -0
  18. package/dist/pie/math-rendering.js +6 -2
  19. package/dist/pie/types.d.ts +10 -0
  20. package/dist/pie/utils.d.ts +27 -0
  21. package/dist/pie/utils.js +56 -1
  22. package/dist/security/index.d.ts +3 -2
  23. package/dist/security/index.js +3 -2
  24. package/dist/security/sanitize-forbidden-lists.js +9 -0
  25. package/dist/security/sanitize-item-markup.js +4 -0
  26. package/dist/security/sanitize-style-attribute.d.ts +48 -0
  27. package/dist/security/sanitize-style-attribute.js +129 -0
  28. package/dist/security/sanitize-svg-icon.js +2 -0
  29. package/dist/security/validate-style-url.d.ts +13 -0
  30. package/dist/security/validate-style-url.js +36 -2
  31. package/dist/security/wrap-overwide-images.d.ts +7 -0
  32. package/dist/security/wrap-overwide-images.js +10 -1
  33. package/dist/security/wrap-overwide-tables.d.ts +7 -0
  34. package/dist/security/wrap-overwide-tables.js +10 -1
  35. package/dist/security/wrap-overwide.d.ts +16 -0
  36. package/dist/security/wrap-overwide.js +52 -0
  37. package/dist/ui/overlay-containment.d.ts +48 -0
  38. package/dist/ui/overlay-containment.js +65 -0
  39. package/package.json +6 -10
  40. package/dist/ui/zoom-compensation.d.ts +0 -44
  41. package/dist/ui/zoom-compensation.js +0 -43
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Declaration-level filter for the `style` attribute on authored markup and
3
+ * tool icons.
4
+ *
5
+ * DOMPurify lists `style` among its URI-safe attributes, so it permits the
6
+ * attribute and inspects nothing inside it. Two things in an inline style
7
+ * therefore reach the page unchecked: a URL-fetching function, which makes the
8
+ * browser request an arbitrary origin every time the item renders and reports
9
+ * back which learner saw which item, and `position: fixed`, which leaves the
10
+ * item's box and covers the host page — the item player renders in light DOM
11
+ * (`shadow: "none"`), so nothing else confines it.
12
+ *
13
+ * What stays permitted is the point of filtering declarations rather than
14
+ * dropping the attribute: authored items use inline styles for ordinary
15
+ * per-element presentation, and `position: absolute` in particular is
16
+ * load-bearing for accessibility — MathJax's `mjx-assistive-mml` carries
17
+ * `position: absolute; width: 1px; height: 1px; overflow: hidden` to expose
18
+ * MathML to a screen reader while hiding it visually.
19
+ *
20
+ * `position: absolute` and `position: sticky` are deliberately left alone.
21
+ * Sticky cannot leave its containing block, so it is not an escape. Absolute
22
+ * can, when no ancestor between the node and the viewport is positioned, and
23
+ * closing that means making the player's own container a containing block
24
+ * rather than filtering the declaration — a change that re-anchors every
25
+ * absolutely positioned node a PIE element renders, so it is a separate
26
+ * decision with its own visual review.
27
+ */
28
+ /**
29
+ * Filter one `style` attribute value.
30
+ *
31
+ * Returns the input unchanged when nothing needs removing, so authored markup
32
+ * keeps its own spelling — shorthands stay shorthands — in every case but the
33
+ * one that carries something forbidden.
34
+ */
35
+ export declare function sanitizeStyleAttribute(value: unknown, doc: Document | null | undefined): string;
36
+ /** The subset of a DOMPurify instance this hook needs. */
37
+ export interface StyleAttributeHookTarget {
38
+ addHook?: (entryPoint: string, hook: (node: unknown, data?: unknown) => void) => void;
39
+ }
40
+ /**
41
+ * Install the filter on a DOMPurify instance.
42
+ *
43
+ * `afterSanitizeAttributes` rather than a post-pass over the output string:
44
+ * DOMPurify has already parsed the markup at that point, so the declarations
45
+ * are filtered on the node it is about to serialize instead of costing another
46
+ * DOM round-trip.
47
+ */
48
+ export declare function installStyleAttributeHook(purifier: StyleAttributeHookTarget): void;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Declaration-level filter for the `style` attribute on authored markup and
3
+ * tool icons.
4
+ *
5
+ * DOMPurify lists `style` among its URI-safe attributes, so it permits the
6
+ * attribute and inspects nothing inside it. Two things in an inline style
7
+ * therefore reach the page unchecked: a URL-fetching function, which makes the
8
+ * browser request an arbitrary origin every time the item renders and reports
9
+ * back which learner saw which item, and `position: fixed`, which leaves the
10
+ * item's box and covers the host page — the item player renders in light DOM
11
+ * (`shadow: "none"`), so nothing else confines it.
12
+ *
13
+ * What stays permitted is the point of filtering declarations rather than
14
+ * dropping the attribute: authored items use inline styles for ordinary
15
+ * per-element presentation, and `position: absolute` in particular is
16
+ * load-bearing for accessibility — MathJax's `mjx-assistive-mml` carries
17
+ * `position: absolute; width: 1px; height: 1px; overflow: hidden` to expose
18
+ * MathML to a screen reader while hiding it visually.
19
+ *
20
+ * `position: absolute` and `position: sticky` are deliberately left alone.
21
+ * Sticky cannot leave its containing block, so it is not an escape. Absolute
22
+ * can, when no ancestor between the node and the viewport is positioned, and
23
+ * closing that means making the player's own container a containing block
24
+ * rather than filtering the declaration — a change that re-anchors every
25
+ * absolutely positioned node a PIE element renders, so it is a separate
26
+ * decision with its own visual review.
27
+ */
28
+ /**
29
+ * CSS functions that make the browser fetch a URL. Serialized CSSOM values are
30
+ * matched against this, so an escaped spelling (`\75 rl(`) is normalized to
31
+ * `url(` before it gets here.
32
+ */
33
+ const URL_FUNCTION_REGEX = /(?:^|[^\w-])(?:url|image-set|-webkit-image-set|src)\s*\(/i;
34
+ /**
35
+ * Cheap gate on the raw attribute so the common case costs one regex and no
36
+ * CSSOM parse. `position` is included because that check needs the parser, and
37
+ * a backslash because it introduces a CSS escape, which is exactly how a
38
+ * forbidden token hides from a raw-string match.
39
+ */
40
+ const NEEDS_INSPECTION_REGEX = /url\s*\(|image-set\s*\(|src\s*\(|position|\\/i;
41
+ function isForbiddenDeclaration(property, value) {
42
+ if (URL_FUNCTION_REGEX.test(value))
43
+ return true;
44
+ // `position` is a shorthand for nothing, so the CSSOM reports it verbatim.
45
+ return property.toLowerCase() === "position" && /\bfixed\b/i.test(value);
46
+ }
47
+ /**
48
+ * Rebuild the attribute from the parsed declarations, dropping the forbidden
49
+ * ones. Anything the engine failed to parse is dropped with them: an unparsed
50
+ * declaration cannot be inspected, and this path only runs on input that
51
+ * already tripped the gate.
52
+ */
53
+ function rebuildFromCssom(value, doc) {
54
+ const probe = doc.createElement("span");
55
+ probe.setAttribute("style", value);
56
+ const declarations = probe.style;
57
+ const kept = [];
58
+ for (let index = 0; index < declarations.length; index += 1) {
59
+ const property = declarations.item(index);
60
+ if (!property)
61
+ continue;
62
+ const propertyValue = declarations.getPropertyValue(property);
63
+ if (isForbiddenDeclaration(property, propertyValue))
64
+ continue;
65
+ const priority = declarations.getPropertyPriority(property);
66
+ kept.push(`${property}: ${propertyValue}${priority ? ` !${priority}` : ""}`);
67
+ }
68
+ return kept.join("; ");
69
+ }
70
+ /**
71
+ * Filter one `style` attribute value.
72
+ *
73
+ * Returns the input unchanged when nothing needs removing, so authored markup
74
+ * keeps its own spelling — shorthands stay shorthands — in every case but the
75
+ * one that carries something forbidden.
76
+ */
77
+ export function sanitizeStyleAttribute(value, doc) {
78
+ if (typeof value !== "string" || value.length === 0)
79
+ return "";
80
+ if (!doc)
81
+ return value;
82
+ if (!NEEDS_INSPECTION_REGEX.test(value))
83
+ return value;
84
+ const probe = doc.createElement("span");
85
+ probe.setAttribute("style", value);
86
+ const declarations = probe.style;
87
+ let hasForbidden = false;
88
+ for (let index = 0; index < declarations.length; index += 1) {
89
+ const property = declarations.item(index);
90
+ if (!property)
91
+ continue;
92
+ if (isForbiddenDeclaration(property, declarations.getPropertyValue(property))) {
93
+ hasForbidden = true;
94
+ break;
95
+ }
96
+ }
97
+ // A raw value carrying a URL function the engine did not parse into a
98
+ // declaration still goes through the rebuild, which drops it.
99
+ if (!hasForbidden && !URL_FUNCTION_REGEX.test(value))
100
+ return value;
101
+ return rebuildFromCssom(value, doc);
102
+ }
103
+ /**
104
+ * Install the filter on a DOMPurify instance.
105
+ *
106
+ * `afterSanitizeAttributes` rather than a post-pass over the output string:
107
+ * DOMPurify has already parsed the markup at that point, so the declarations
108
+ * are filtered on the node it is about to serialize instead of costing another
109
+ * DOM round-trip.
110
+ */
111
+ export function installStyleAttributeHook(purifier) {
112
+ if (typeof purifier.addHook !== "function")
113
+ return;
114
+ purifier.addHook("afterSanitizeAttributes", (node) => {
115
+ const element = node;
116
+ if (!element || typeof element.getAttribute !== "function")
117
+ return;
118
+ const raw = element.getAttribute("style");
119
+ if (!raw)
120
+ return;
121
+ const filtered = sanitizeStyleAttribute(raw, element.ownerDocument);
122
+ if (filtered === raw)
123
+ return;
124
+ if (filtered)
125
+ element.setAttribute("style", filtered);
126
+ else
127
+ element.removeAttribute("style");
128
+ });
129
+ }
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import DOMPurify from "dompurify";
13
13
  import { SANITIZER_FORBIDDEN_ATTRS, SANITIZER_FORBIDDEN_TAGS, } from "./sanitize-forbidden-lists.js";
14
+ import { installStyleAttributeHook } from "./sanitize-style-attribute.js";
14
15
  let svgPurifierInstance = null;
15
16
  function resolveSvgPurifier() {
16
17
  if (svgPurifierInstance)
@@ -22,6 +23,7 @@ function resolveSvgPurifier() {
22
23
  typeof factory === "function"
23
24
  ? factory(window)
24
25
  : DOMPurify;
26
+ installStyleAttributeHook(svgPurifierInstance);
25
27
  return svgPurifierInstance;
26
28
  }
27
29
  const FORBIDDEN_TAGS = SANITIZER_FORBIDDEN_TAGS;
@@ -8,6 +8,13 @@
8
8
  * - When `allowedOrigins` is non-empty, the URL's origin must match one
9
9
  * of the listed origins. This lets hosts restrict style loading to a
10
10
  * known CDN allow-list.
11
+ * - With no allow-list configured, only same-origin URLs pass. The reachable
12
+ * input here is authored — `itemConfig.resources.stylesheets[*].url` — so an
13
+ * open default let an item pull page-wide CSS from any origin it named, and
14
+ * the cross-origin branch in the player is the one that cannot be scoped
15
+ * (CSS the browser applies from a `<link>` rather than text the player
16
+ * fetched and rewrote). Naming an origin in `allowed-style-origins` is a
17
+ * host's opt-in to that.
11
18
  */
12
19
  export type StyleUrlValidationOk = {
13
20
  ok: true;
@@ -20,6 +27,12 @@ export type StyleUrlValidationError = {
20
27
  };
21
28
  export type StyleUrlValidationResult = StyleUrlValidationOk | StyleUrlValidationError;
22
29
  export interface StyleUrlValidationOptions {
30
+ /**
31
+ * Document URL the stylesheet URL resolves against, and the origin a URL is
32
+ * compared to when no `allowedOrigins` are configured. Omitting it while
33
+ * supplying no allow-list leaves no origin to compare against, so
34
+ * cross-origin cannot be ruled out and the URL is rejected.
35
+ */
23
36
  baseUrl?: string;
24
37
  allowedOrigins?: string[];
25
38
  }
@@ -8,6 +8,13 @@
8
8
  * - When `allowedOrigins` is non-empty, the URL's origin must match one
9
9
  * of the listed origins. This lets hosts restrict style loading to a
10
10
  * known CDN allow-list.
11
+ * - With no allow-list configured, only same-origin URLs pass. The reachable
12
+ * input here is authored — `itemConfig.resources.stylesheets[*].url` — so an
13
+ * open default let an item pull page-wide CSS from any origin it named, and
14
+ * the cross-origin branch in the player is the one that cannot be scoped
15
+ * (CSS the browser applies from a `<link>` rather than text the player
16
+ * fetched and rewrote). Naming an origin in `allowed-style-origins` is a
17
+ * host's opt-in to that.
11
18
  */
12
19
  export function validateExternalStyleUrl(url, options = {}) {
13
20
  if (typeof url !== "string" || url.length === 0) {
@@ -38,11 +45,38 @@ export function validateExternalStyleUrl(url, options = {}) {
38
45
  };
39
46
  }
40
47
  const allowed = options.allowedOrigins ?? [];
41
- if (allowed.length > 0 && !allowed.includes(resolvedUrl.origin)) {
48
+ if (allowed.length > 0) {
49
+ if (!allowed.includes(resolvedUrl.origin)) {
50
+ return {
51
+ ok: false,
52
+ reason: "disallowed-origin",
53
+ message: `External stylesheet origin ${resolvedUrl.origin} is not in the configured allow-list.`,
54
+ };
55
+ }
56
+ return { ok: true, resolvedUrl };
57
+ }
58
+ // No allow-list: same-origin only.
59
+ let baseOrigin = null;
60
+ if (options.baseUrl) {
61
+ try {
62
+ baseOrigin = new URL(options.baseUrl).origin;
63
+ }
64
+ catch {
65
+ baseOrigin = null;
66
+ }
67
+ }
68
+ if (baseOrigin === null) {
69
+ return {
70
+ ok: false,
71
+ reason: "disallowed-origin",
72
+ message: "External stylesheet origin cannot be checked: no allow-list is configured and no usable baseUrl was supplied. Pass allowedOrigins to permit a cross-origin stylesheet.",
73
+ };
74
+ }
75
+ if (resolvedUrl.origin !== baseOrigin) {
42
76
  return {
43
77
  ok: false,
44
78
  reason: "disallowed-origin",
45
- message: `External stylesheet origin ${resolvedUrl.origin} is not in the configured allow-list.`,
79
+ message: `External stylesheet origin ${resolvedUrl.origin} is cross-origin and no allow-list is configured. Add it to \`allowed-style-origins\` to permit it.`,
46
80
  };
47
81
  }
48
82
  return { ok: true, resolvedUrl };
@@ -49,3 +49,10 @@ export declare function wrapOverwideImagesInElement(root: Element, options?: Wra
49
49
  * to wrap element-rendered images.
50
50
  */
51
51
  export declare function wrapOverwideImages(markup: string): string;
52
+ /**
53
+ * True when `record` mentions nothing but the output of
54
+ * {@link wrapOverwideImagesInElement} — a `pie-image-scroll` wrapper, or an
55
+ * `<img>` moving inside one. An observer-driven caller ignores such a record so
56
+ * its own wrap does not schedule a second pass.
57
+ */
58
+ export declare function isOverwideImageWrapMutation(record: MutationRecord): boolean;
@@ -26,7 +26,7 @@
26
26
  * The wrapping itself lives in `./wrap-overwide.js`, shared with the table
27
27
  * wrapper: only the four values below and the accessible name differ.
28
28
  */
29
- import { wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
29
+ import { isOverwideWrapMutation, wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
30
30
  function buildAriaLabel(image) {
31
31
  const alt = image.getAttribute("alt");
32
32
  const trimmed = alt ? alt.trim() : "";
@@ -64,3 +64,12 @@ export function wrapOverwideImagesInElement(root, options = {}) {
64
64
  export function wrapOverwideImages(markup) {
65
65
  return wrapOverwideMarkup(markup, IMAGE_SPEC);
66
66
  }
67
+ /**
68
+ * True when `record` mentions nothing but the output of
69
+ * {@link wrapOverwideImagesInElement} — a `pie-image-scroll` wrapper, or an
70
+ * `<img>` moving inside one. An observer-driven caller ignores such a record so
71
+ * its own wrap does not schedule a second pass.
72
+ */
73
+ export function isOverwideImageWrapMutation(record) {
74
+ return isOverwideWrapMutation(record, IMAGE_SPEC);
75
+ }
@@ -35,3 +35,10 @@ export type WrapOverwideTablesInElementOptions = WrapOverwideOptions;
35
35
  */
36
36
  export declare function wrapOverwideTablesInElement(root: Element, options?: WrapOverwideTablesInElementOptions): number;
37
37
  export declare function wrapOverwideTables(markup: string): string;
38
+ /**
39
+ * True when `record` mentions nothing but the output of
40
+ * {@link wrapOverwideTablesInElement} — a `pie-table-scroll` wrapper, or a
41
+ * `<table>` moving inside one. An observer-driven caller ignores such a record
42
+ * so its own wrap does not schedule a second pass.
43
+ */
44
+ export declare function isOverwideTableWrapMutation(record: MutationRecord): boolean;
@@ -26,7 +26,7 @@
26
26
  * The wrapping itself lives in `./wrap-overwide.js`, shared with the image
27
27
  * wrapper: only the four values below and the accessible name differ.
28
28
  */
29
- import { wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
29
+ import { isOverwideWrapMutation, wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
30
30
  function buildAriaLabel(table) {
31
31
  // Authors commonly label tables via <caption>, aria-label, or aria-labelledby.
32
32
  // Prefer the most explicit signal and fall back to the generic label so
@@ -75,3 +75,12 @@ export function wrapOverwideTablesInElement(root, options = {}) {
75
75
  export function wrapOverwideTables(markup) {
76
76
  return wrapOverwideMarkup(markup, TABLE_SPEC);
77
77
  }
78
+ /**
79
+ * True when `record` mentions nothing but the output of
80
+ * {@link wrapOverwideTablesInElement} — a `pie-table-scroll` wrapper, or a
81
+ * `<table>` moving inside one. An observer-driven caller ignores such a record
82
+ * so its own wrap does not schedule a second pass.
83
+ */
84
+ export function isOverwideTableWrapMutation(record) {
85
+ return isOverwideWrapMutation(record, TABLE_SPEC);
86
+ }
@@ -49,3 +49,19 @@ export declare function wrapOverwideInElement(root: Element, spec: OverwideWrapS
49
49
  * is returned unchanged and the browser re-run on hydrate performs the wrap.
50
50
  */
51
51
  export declare function wrapOverwideMarkup(markup: string, spec: OverwideWrapSpec): string;
52
+ /**
53
+ * True when `record` mentions nothing but this module's own live-DOM output for
54
+ * `spec`: a wrapper landing beside the node it wraps, or that node moving inside
55
+ * it.
56
+ *
57
+ * An observer-driven caller re-runs {@link wrapOverwideInElement} on every
58
+ * mutation tick, and the wrap itself inserts elements — so without this test the
59
+ * pass retriggers the observer that scheduled it, converging only because the
60
+ * wrap is idempotent. Ignoring the pass's own records is what removes the
61
+ * retrigger; a PIE element that re-renders over its own subtree and drops the
62
+ * wrapper is the case where absorbing it instead becomes a sustained loop.
63
+ *
64
+ * Conservative in the safe direction: anything it cannot account for reads as
65
+ * foreign, which costs one extra pass and never a missed wrap.
66
+ */
67
+ export declare function isOverwideWrapMutation(record: MutationRecord, spec: OverwideWrapSpec): boolean;
@@ -89,3 +89,55 @@ export function wrapOverwideMarkup(markup, spec) {
89
89
  });
90
90
  return wrapped > 0 ? body.innerHTML : markup;
91
91
  }
92
+ function isElement(node) {
93
+ return node.nodeType === 1;
94
+ }
95
+ /** A `spec` wrapper holding nothing but the nodes `spec` wraps. */
96
+ function isWrapperFor(node, spec) {
97
+ if (!isElement(node))
98
+ return false;
99
+ if (!node.classList?.contains(spec.wrapperClass))
100
+ return false;
101
+ for (const child of Array.from(node.children)) {
102
+ if (!child.matches(spec.selector))
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+ /** A node `spec` wraps, currently held by a `spec` wrapper. */
108
+ function isWrappedBy(node, spec) {
109
+ if (!isElement(node))
110
+ return false;
111
+ const parent = node.parentElement;
112
+ if (!parent?.classList?.contains(spec.wrapperClass))
113
+ return false;
114
+ return node.matches(spec.selector);
115
+ }
116
+ function everyNodeIsWrapOutput(nodes, spec) {
117
+ for (const node of Array.from(nodes)) {
118
+ if (!isWrapperFor(node, spec) && !isWrappedBy(node, spec))
119
+ return false;
120
+ }
121
+ return true;
122
+ }
123
+ /**
124
+ * True when `record` mentions nothing but this module's own live-DOM output for
125
+ * `spec`: a wrapper landing beside the node it wraps, or that node moving inside
126
+ * it.
127
+ *
128
+ * An observer-driven caller re-runs {@link wrapOverwideInElement} on every
129
+ * mutation tick, and the wrap itself inserts elements — so without this test the
130
+ * pass retriggers the observer that scheduled it, converging only because the
131
+ * wrap is idempotent. Ignoring the pass's own records is what removes the
132
+ * retrigger; a PIE element that re-renders over its own subtree and drops the
133
+ * wrapper is the case where absorbing it instead becomes a sustained loop.
134
+ *
135
+ * Conservative in the safe direction: anything it cannot account for reads as
136
+ * foreign, which costs one extra pass and never a missed wrap.
137
+ */
138
+ export function isOverwideWrapMutation(record, spec) {
139
+ if (record.type !== "childList")
140
+ return false;
141
+ return (everyNodeIsWrapOutput(record.addedNodes, spec) &&
142
+ everyNodeIsWrapOutput(record.removedNodes, spec));
143
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Containment for tool overlays that position themselves.
3
+ *
4
+ * An overlay that draws its own surface computes its own coordinates, and those
5
+ * coordinates are only meaningful against the box the browser resolved as its
6
+ * containing block. Reading that box from the element rather than from
7
+ * `window` is what keeps a tool correct wherever a host mounts it: the same
8
+ * numbers that centre a tool in the viewport put it outside a pane.
9
+ */
10
+ /** Distance kept clear of the block's edges so an overlay's controls stay reachable. */
11
+ export declare const DEFAULT_CONTAINMENT_GUTTER = 4;
12
+ export interface Size {
13
+ width: number;
14
+ height: number;
15
+ }
16
+ export interface Point {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ /**
21
+ * The box `element` resolves its `left`/`top` against: its containing block, or
22
+ * the viewport when that is the initial containing block.
23
+ *
24
+ * `offsetParent` is the containing block as the browser resolved it, including
25
+ * across a shadow boundary, which is what makes this correct for a tool mounted
26
+ * by a host it knows nothing about. Returns `undefined` outside a browser.
27
+ */
28
+ export declare function resolveContainingBlockRect(element: HTMLElement | undefined | null): DOMRect | undefined;
29
+ /**
30
+ * Clamps a translate offset applied to a box that is already centred in its
31
+ * containing block, so the box stays inside it.
32
+ *
33
+ * The travel available on each axis is symmetric about the centre: half the
34
+ * block, less half the box and the gutter. A box wider or taller than its block
35
+ * cannot satisfy that on the offending axis, so it stays centred there rather
36
+ * than clamping to an inverted range.
37
+ */
38
+ export declare function clampOffsetWithinBlock(offset: Point, box: Size, block: Size, gutter?: number): Point;
39
+ /**
40
+ * Clamps an absolute centre point, in containing-block coordinates, so the box
41
+ * it positions stays inside that block.
42
+ *
43
+ * The same invariant as `clampOffsetWithinBlock` in the coordinate system an
44
+ * overlay uses when it writes `left`/`top` instead of translating: the two are
45
+ * conjugate by a translation of half the block, including the degenerate case
46
+ * where a box exceeds its block and centres on the offending axis.
47
+ */
48
+ export declare function clampPointWithinBlock(point: Point, box: Size, block: Size, gutter?: number): Point;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Containment for tool overlays that position themselves.
3
+ *
4
+ * An overlay that draws its own surface computes its own coordinates, and those
5
+ * coordinates are only meaningful against the box the browser resolved as its
6
+ * containing block. Reading that box from the element rather than from
7
+ * `window` is what keeps a tool correct wherever a host mounts it: the same
8
+ * numbers that centre a tool in the viewport put it outside a pane.
9
+ */
10
+ /** Distance kept clear of the block's edges so an overlay's controls stay reachable. */
11
+ export const DEFAULT_CONTAINMENT_GUTTER = 4;
12
+ /**
13
+ * The box `element` resolves its `left`/`top` against: its containing block, or
14
+ * the viewport when that is the initial containing block.
15
+ *
16
+ * `offsetParent` is the containing block as the browser resolved it, including
17
+ * across a shadow boundary, which is what makes this correct for a tool mounted
18
+ * by a host it knows nothing about. Returns `undefined` outside a browser.
19
+ */
20
+ export function resolveContainingBlockRect(element) {
21
+ if (typeof window === "undefined" || !element)
22
+ return undefined;
23
+ const parent = element.offsetParent;
24
+ if (parent instanceof HTMLElement) {
25
+ const rect = parent.getBoundingClientRect();
26
+ if (rect.width > 0 && rect.height > 0)
27
+ return rect;
28
+ }
29
+ return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
30
+ }
31
+ /**
32
+ * Clamps a translate offset applied to a box that is already centred in its
33
+ * containing block, so the box stays inside it.
34
+ *
35
+ * The travel available on each axis is symmetric about the centre: half the
36
+ * block, less half the box and the gutter. A box wider or taller than its block
37
+ * cannot satisfy that on the offending axis, so it stays centred there rather
38
+ * than clamping to an inverted range.
39
+ */
40
+ export function clampOffsetWithinBlock(offset, box, block, gutter = DEFAULT_CONTAINMENT_GUTTER) {
41
+ const clampAxis = (value, boxExtent, blockExtent) => {
42
+ const travel = blockExtent / 2 - boxExtent / 2 - gutter;
43
+ if (travel <= 0)
44
+ return 0;
45
+ return Math.max(-travel, Math.min(travel, value));
46
+ };
47
+ return {
48
+ x: clampAxis(offset.x, box.width, block.width),
49
+ y: clampAxis(offset.y, box.height, block.height),
50
+ };
51
+ }
52
+ /**
53
+ * Clamps an absolute centre point, in containing-block coordinates, so the box
54
+ * it positions stays inside that block.
55
+ *
56
+ * The same invariant as `clampOffsetWithinBlock` in the coordinate system an
57
+ * overlay uses when it writes `left`/`top` instead of translating: the two are
58
+ * conjugate by a translation of half the block, including the degenerate case
59
+ * where a box exceeds its block and centres on the offending axis.
60
+ */
61
+ export function clampPointWithinBlock(point, box, block, gutter = DEFAULT_CONTAINMENT_GUTTER) {
62
+ const centre = { x: block.width / 2, y: block.height / 2 };
63
+ const offset = clampOffsetWithinBlock({ x: point.x - centre.x, y: point.y - centre.y }, box, block, gutter);
64
+ return { x: centre.x + offset.x, y: centre.y + offset.y };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-players-shared",
3
- "version": "0.3.69",
3
+ "version": "0.3.71",
4
4
  "type": "module",
5
5
  "description": "Shared runtime + UI utilities for PIE players",
6
6
  "license": "MIT",
@@ -89,10 +89,6 @@
89
89
  "types": "./dist/components/vendor/nds/nds-icon-button.d.ts",
90
90
  "import": "./dist/components/vendor/nds/nds-icon-button.js"
91
91
  },
92
- "./ui/zoom-compensation": {
93
- "types": "./dist/ui/zoom-compensation.d.ts",
94
- "import": "./dist/ui/zoom-compensation.js"
95
- },
96
92
  "./ui/content-styles": {
97
93
  "types": "./dist/ui/content-styles.d.ts",
98
94
  "import": "./dist/ui/content-styles.js"
@@ -107,7 +103,7 @@
107
103
  ],
108
104
  "dependencies": {
109
105
  "@pie-lib/math-rendering-module": "6.0.0",
110
- "dompurify": "^3.4.13",
106
+ "dompurify": "^3.4.14",
111
107
  "semver": "^7.8.5"
112
108
  },
113
109
  "scripts": {
@@ -116,18 +112,18 @@
116
112
  "typecheck": "tsc -p tsconfig.json --noEmit",
117
113
  "lint": "biome check .",
118
114
  "check": "tsc -p tsconfig.json --noEmit",
119
- "test": "bun test '.test.ts'",
115
+ "test": "bun test",
120
116
  "check-i18n": "bun run src/i18n/scripts/check-coverage.ts",
121
117
  "scan-hardcoded": "bun run src/i18n/scripts/scan-hardcoded.ts"
122
118
  },
123
119
  "devDependencies": {
124
- "@biomejs/biome": "^2.5.10",
125
- "@happy-dom/global-registrator": "^20.11.6",
120
+ "@biomejs/biome": "^2.5.11",
121
+ "@happy-dom/global-registrator": "^20.11.15",
126
122
  "@playwright/test": "^1.62.1",
127
123
  "@types/semver": "^7.8.0",
128
124
  "esbuild": "^0.28.2",
129
125
  "glob": "^13.0.0",
130
- "svelte": "^5.56.10",
126
+ "svelte": "^5.57.0",
131
127
  "typescript": "^5.9.3"
132
128
  },
133
129
  "homepage": "https://github.com/pie-framework/pie-players/tree/master/packages/players-shared#readme",
@@ -1,44 +0,0 @@
1
- /**
2
- * Pure browser-zoom compensation math (framework-agnostic, no Svelte runes).
3
- *
4
- * Browser zoom is approximated as outerWidth / innerWidth: outerWidth is the
5
- * OS window size (zoom-independent) while innerWidth is in CSS pixels (shrinks
6
- * as zoom increases). Callers apply the returned factor via CSS `zoom` so an
7
- * element that would otherwise keep enlarging past `maxZoom` compounds back
8
- * down to the cap.
9
- *
10
- * This module is tsc-buildable and shipped in `dist`, so it can be consumed by
11
- * packages that resolve `@pie-players/pie-players-shared` from its published
12
- * build (e.g. the assessment-toolkit CE bundle) as well as by the reactive
13
- * Svelte wrapper in `./use-zoom-compensation.svelte.ts`, which packages built
14
- * with Vite alias to source.
15
- */
16
- export type ZoomCompensationOptions = {
17
- /**
18
- * Zoom level (as a ratio, e.g. `2` for 200%) below which the factor is 1.
19
- * Above this level the factor shrinks as `maxZoom / zoom`.
20
- */
21
- maxZoom: number;
22
- /** Lower bound on the returned factor. */
23
- minCompensation: number;
24
- };
25
- /**
26
- * Shared zoom-cap settings for the toolbar icon buttons (the TTS play button and
27
- * the calculator button) so both compensate identically and can't drift apart.
28
- * Grow normally up to 200%, then freeze; the 0.25 floor keeps the cap holding
29
- * past 500% browser zoom (see the minCompensation note in
30
- * section-player's SectionPlayerTabbedContent).
31
- */
32
- export declare const ICON_BUTTON_ZOOM_OPTIONS: ZoomCompensationOptions;
33
- /**
34
- * Approximate the browser zoom level from an outer/inner width pair. Falls
35
- * back to 1 (100%) if the ratio isn't finite or is non-positive (which happens
36
- * during SSR, in headless envs, and briefly during resize on some browsers).
37
- */
38
- export declare function approximateZoomFromWidths(outerWidth: number, innerWidth: number): number;
39
- /**
40
- * Pure zoom-compensation math: exactly 1 at zoom <= maxZoom, then shrinks as
41
- * `maxZoom / zoom`, floored at `minCompensation` to guard against inflated
42
- * ratios from window chrome / side panels making the element unusably small.
43
- */
44
- export declare function computeZoomCompensation(zoom: number, maxZoom: number, minCompensation: number): number;