@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.
- package/dist/i18n/messages/en-US.d.ts +2 -0
- package/dist/i18n/messages/en-US.js +2 -0
- package/dist/i18n/messages/nl-NL.d.ts +2 -0
- package/dist/i18n/messages/nl-NL.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loaders/iife-adapter.js +7 -3
- package/dist/pie/element-observer.d.ts +59 -0
- package/dist/pie/element-observer.js +131 -0
- package/dist/pie/index.d.ts +4 -2
- package/dist/pie/index.js +7 -2
- package/dist/pie/initialization.d.ts +13 -13
- package/dist/pie/initialization.js +97 -147
- package/dist/pie/initialize-element.d.ts +23 -0
- package/dist/pie/initialize-element.js +78 -0
- package/dist/pie/instrumentation-event-map.d.ts +1 -0
- package/dist/pie/instrumentation-event-map.js +18 -0
- package/dist/pie/math-rendering.js +6 -2
- package/dist/pie/types.d.ts +10 -0
- package/dist/pie/utils.d.ts +27 -0
- package/dist/pie/utils.js +56 -1
- package/dist/security/index.d.ts +3 -2
- package/dist/security/index.js +3 -2
- package/dist/security/sanitize-forbidden-lists.js +9 -0
- package/dist/security/sanitize-item-markup.js +4 -0
- package/dist/security/sanitize-style-attribute.d.ts +48 -0
- package/dist/security/sanitize-style-attribute.js +129 -0
- package/dist/security/sanitize-svg-icon.js +2 -0
- package/dist/security/validate-style-url.d.ts +13 -0
- package/dist/security/validate-style-url.js +36 -2
- package/dist/security/wrap-overwide-images.d.ts +7 -0
- package/dist/security/wrap-overwide-images.js +10 -1
- package/dist/security/wrap-overwide-tables.d.ts +7 -0
- package/dist/security/wrap-overwide-tables.js +10 -1
- package/dist/security/wrap-overwide.d.ts +16 -0
- package/dist/security/wrap-overwide.js +52 -0
- package/dist/ui/overlay-containment.d.ts +48 -0
- package/dist/ui/overlay-containment.js +65 -0
- package/package.json +6 -10
- package/dist/ui/zoom-compensation.d.ts +0 -44
- 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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
125
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
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.
|
|
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;
|