@readium/navigator-html-injectables 2.4.3 → 2.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator-html-injectables",
3
- "version": "2.4.3",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator",
6
6
  "author": "readium",
@@ -54,6 +54,7 @@
54
54
  "rimraf": "^6.1.2",
55
55
  "tslib": "^2.8.1",
56
56
  "typescript": "^5.9.3",
57
+ "user-agent-data-types": "^0.4.2",
57
58
  "vite": "^7.3.1"
58
59
  }
59
60
  }
package/src/comms/keys.ts CHANGED
@@ -20,8 +20,8 @@ export type CommsEventKey =
20
20
  "media_play" |
21
21
  "media_pause" |
22
22
  "content_protection" |
23
- "keyboard_peripherals";
24
- ;
23
+ "keyboard_peripherals" |
24
+ "decoration_activated";
25
25
 
26
26
  export type CommsCommandKey =
27
27
  "_ping" |
@@ -39,6 +39,7 @@ export type CommsCommandKey =
39
39
  // "exact_progress" |
40
40
  "first_visible_locator" |
41
41
  "decorate" |
42
+ "decoration_activatable" |
42
43
  "protect" |
43
44
  "unprotect" |
44
45
  "unfocus" |
@@ -202,4 +202,67 @@ export const isLightColor = (color: string, blendedWith: string | null = null):
202
202
 
203
203
  export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => {
204
204
  return isDarkColor(color, blendedWith) ? "white" : "black";
205
+ };
206
+
207
+ const rgbaToCss = (rgba: { r: number; g: number; b: number; a?: number }): string => {
208
+ const a = rgba.a !== undefined ? rgba.a : 1;
209
+ return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${a})`;
210
+ };
211
+
212
+ const lightenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
213
+ return {
214
+ r: Math.min(255, rgba.r + (255 - rgba.r) * factor),
215
+ g: Math.min(255, rgba.g + (255 - rgba.g) * factor),
216
+ b: Math.min(255, rgba.b + (255 - rgba.b) * factor),
217
+ a: rgba.a ?? 1
218
+ };
219
+ };
220
+
221
+ const darkenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
222
+ return {
223
+ r: Math.max(0, rgba.r * (1 - factor)),
224
+ g: Math.max(0, rgba.g * (1 - factor)),
225
+ b: Math.max(0, rgba.b * (1 - factor)),
226
+ a: rgba.a ?? 1
227
+ };
228
+ };
229
+
230
+ export const adjustColorForContrast = (
231
+ baseColor: string,
232
+ backgroundColor: string | null = null,
233
+ targetContrast: number = 3
234
+ ): string => {
235
+ const baseRgba = colorToRgba(baseColor);
236
+ const bgRgba = backgroundColor ? colorToRgba(backgroundColor) : { r: 255, g: 255, b: 255, a: 1 };
237
+
238
+ let currentContrast = checkContrast(baseRgba, bgRgba);
239
+
240
+ // If already meets target, return as-is
241
+ if (currentContrast >= targetContrast) {
242
+ return baseColor;
243
+ }
244
+
245
+ const bgLuminance = getLuminance(bgRgba);
246
+ const isBgDark = bgLuminance < 0.5;
247
+
248
+ let adjustedRgba = { ...baseRgba, a: baseRgba.a ?? 1 };
249
+ const maxIterations = 20;
250
+ const step = 0.1;
251
+
252
+ for (let i = 0; i < maxIterations; i++) {
253
+ if (isBgDark) {
254
+ // Dark background: lighten the base color
255
+ adjustedRgba = lightenColor(adjustedRgba, step);
256
+ } else {
257
+ // Light background: darken the base color
258
+ adjustedRgba = darkenColor(adjustedRgba, step);
259
+ }
260
+
261
+ currentContrast = checkContrast(adjustedRgba, bgRgba);
262
+ if (currentContrast >= targetContrast) {
263
+ break;
264
+ }
265
+ }
266
+
267
+ return rgbaToCss(adjustedRgba);
205
268
  };
@@ -1,5 +1,34 @@
1
1
  import { ReadiumWindow } from "./dom.ts";
2
2
 
3
+ const COSMETIC_PROPERTIES = new Set([
4
+ "backgroundColor", "textColor",
5
+ "linkColor", "visitedColor",
6
+ "primaryColor", "secondaryColor",
7
+ "selectionBackgroundColor", "selectionTextColor",
8
+ "blendFilter", "darkenFilter", "invertFilter", "invertGaiji",
9
+ ]);
10
+
11
+ const CSS_PROP_RE = /--(?:USER|RS)__([\w-]+)/g;
12
+
13
+ /**
14
+ * Returns true if the style attribute change between oldValue and newValue
15
+ * includes at least one ReadiumCSS property that affects document layout/geometry.
16
+ * Pure appearance changes (colors, filters) return false.
17
+ */
18
+ export function styleChangeAffectsLayout(oldValue: string | null, newValue: string | null): boolean {
19
+ const old_ = oldValue ?? "";
20
+ const new_ = newValue ?? "";
21
+ const seen = new Set<string>();
22
+
23
+ for (const match of old_.matchAll(CSS_PROP_RE)) seen.add(match[1]);
24
+ for (const match of new_.matchAll(CSS_PROP_RE)) seen.add(match[1]);
25
+
26
+ for (const suffix of seen) {
27
+ if (!COSMETIC_PROPERTIES.has(suffix)) return true;
28
+ }
29
+ return false;
30
+ }
31
+
3
32
  export function getProperties(wnd: ReadiumWindow) {
4
33
  const cssProperties: { [key: string]: string } = {};
5
34
 
@@ -13,6 +13,126 @@ export function isVerticalLR(wnd: ReadiumWindow): boolean {
13
13
  return writingMode === 'vertical-lr';
14
14
  }
15
15
 
16
+ export function isVerticalWriting(wnd: ReadiumWindow): boolean {
17
+ const writingMode = wnd.getComputedStyle(wnd.document.documentElement).writingMode
18
+ || wnd.getComputedStyle(wnd.document.body).writingMode;
19
+ return writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
20
+ }
21
+
22
+ /**
23
+ * Axis-normalizing context for decoration layout.
24
+ *
25
+ * Translates CSS physical properties into logical inline/block terms so that
26
+ * callers can position elements identically regardless of writing mode:
27
+ *
28
+ * horizontal writing → inline = horizontal, block = vertical
29
+ * vertical writing → inline = vertical, block = horizontal
30
+ */
31
+ export interface WritingContext {
32
+ isVertical: boolean;
33
+ isVertLR: boolean;
34
+ /** Viewport size along the inline axis (innerWidth / innerHeight). */
35
+ viewportInlineSize: number;
36
+ /** Viewport size along the block axis (innerHeight / innerWidth). */
37
+ viewportBlockSize: number;
38
+ /**
39
+ * Size of a single page along the inline axis.
40
+ * Horizontal: viewportWidth / columnCount.
41
+ * Vertical: viewportHeight (no columns).
42
+ */
43
+ pageInlineSize: number;
44
+ /**
45
+ * Physical x offset to add to a client-space x coordinate to get document space.
46
+ * Horizontal: scrollLeft (non-negative).
47
+ * Vertical-rl: scrollWidth − viewportWidth + scrollLeft (scrollLeft is negative,
48
+ * so this normalises it to the viewport's left edge in document coordinates).
49
+ * Vertical-lr: scrollLeft (non-negative, same as horizontal).
50
+ */
51
+ xDocOffset: number;
52
+ /** Physical y offset to add to a client-space y coordinate to get document space (scrollTop). */
53
+ yDocOffset: number;
54
+ /** Scroll offset along the inline axis, derived from xDocOffset / yDocOffset. */
55
+ inlineScrollOffset: number;
56
+ /** Scroll offset along the block axis, derived from xDocOffset / yDocOffset. */
57
+ blockScrollOffset: number;
58
+
59
+ /** Logical inline start of a rect (left / top). */
60
+ inlineStart(r: DOMRect | { top: number; left: number }): number;
61
+ /** Logical block start of a rect (top / left). */
62
+ blockStart(r: DOMRect | { top: number; left: number }): number;
63
+ /** Logical inline size of a rect (width / height). */
64
+ inlineSize(r: DOMRect | { width: number; height: number }): number;
65
+ /** Logical block size of a rect (height / width). */
66
+ blockSize(r: DOMRect | { width: number; height: number }): number;
67
+
68
+ /**
69
+ * Set absolute position on an element using logical coordinates.
70
+ * `iz` is the inverse zoom factor (1 in non-Blink, 1/zoom in Blink).
71
+ */
72
+ applyPosition(el: HTMLElement, inlineStart: number, blockStart: number, inlineSize: number, blockSize: number, iz: number): void;
73
+
74
+ /** Convert logical coordinates back to a physical DOMRect. */
75
+ toRect(inlineStart: number, blockStart: number, inlineSize: number, blockSize: number): DOMRect;
76
+ }
77
+
78
+ export function makeWritingContext(wnd: ReadiumWindow): WritingContext {
79
+ const isVert = isVerticalWriting(wnd);
80
+ const isVLR = isVert && isVerticalLR(wnd);
81
+ const vw = wnd.innerWidth;
82
+ const vh = wnd.innerHeight;
83
+ const se = wnd.document.scrollingElement!;
84
+ const xOff = se.scrollLeft; // negative for vertical-rl, non-negative otherwise
85
+ const yOff = se.scrollTop;
86
+ const cols = parseInt(wnd.getComputedStyle(wnd.document.documentElement).getPropertyValue("column-count"));
87
+
88
+ // In vertical-rl, scrollLeft starts at 0 (document start = right side) and goes
89
+ // negative as the reader scrolls left. The viewport's left edge in document space
90
+ // is therefore (scrollWidth − viewportWidth + scrollLeft). For vertical-lr and
91
+ // horizontal modes, scrollLeft is non-negative and is the offset directly.
92
+ const xDocOffset = (isVert && !isVLR)
93
+ ? se.scrollWidth - vw + xOff
94
+ : xOff;
95
+ const yDocOffset = yOff;
96
+
97
+ return {
98
+ isVertical: isVert,
99
+ isVertLR: isVLR,
100
+ viewportInlineSize: isVert ? vh : vw,
101
+ viewportBlockSize: isVert ? vw : vh,
102
+ pageInlineSize: isVert ? vh : vw / (cols || 1),
103
+ xDocOffset,
104
+ yDocOffset,
105
+ inlineScrollOffset: isVert ? yDocOffset : xDocOffset,
106
+ blockScrollOffset: isVert ? xDocOffset : yDocOffset,
107
+
108
+ inlineStart: (r) => isVert ? r.top : r.left,
109
+ blockStart: (r) => isVert ? r.left : r.top,
110
+ inlineSize: (r) => isVert ? r.height : r.width,
111
+ blockSize: (r) => isVert ? r.width : r.height,
112
+
113
+ applyPosition(el, inlineStart, blockStart, inlineSize, blockSize, iz) {
114
+ el.style.position = "absolute";
115
+ if (isVert) {
116
+ el.style.top = `${inlineStart * iz}px`;
117
+ el.style.left = `${blockStart * iz}px`;
118
+ el.style.height = `${inlineSize * iz}px`;
119
+ el.style.width = `${blockSize * iz}px`;
120
+ } else {
121
+ el.style.left = `${inlineStart * iz}px`;
122
+ el.style.top = `${blockStart * iz}px`;
123
+ el.style.width = `${inlineSize * iz}px`;
124
+ el.style.height = `${blockSize * iz}px`;
125
+ }
126
+ },
127
+
128
+ toRect(inlineStart, blockStart, inlineSize, blockSize) {
129
+ return isVert
130
+ ? new DOMRect(blockStart, inlineStart, blockSize, inlineSize)
131
+ : new DOMRect(inlineStart, blockStart, inlineSize, blockSize);
132
+ },
133
+ };
134
+ }
135
+
16
136
  export function getColumnCountPerScreen(wnd: ReadiumWindow): number {
17
137
  return parseInt(
18
138
  wnd.getComputedStyle(
@@ -12,7 +12,8 @@ export interface Rect {
12
12
 
13
13
  export function getClientRectsNoOverlap(
14
14
  range: Range,
15
- doNotMergeHorizontallyAlignedRects: boolean
15
+ doNotMergeHorizontallyAlignedRects: boolean,
16
+ doNotMergeVerticallyAlignedRects: boolean = false
16
17
  ) {
17
18
  let clientRects = range.getClientRects();
18
19
 
@@ -36,7 +37,8 @@ export function getClientRectsNoOverlap(
36
37
  const mergedRects = mergeTouchingRects(
37
38
  originalRects,
38
39
  tolerance,
39
- doNotMergeHorizontallyAlignedRects
40
+ doNotMergeHorizontallyAlignedRects,
41
+ doNotMergeVerticallyAlignedRects
40
42
  );
41
43
  const noContainedRects = removeContainedRects(mergedRects, tolerance);
42
44
  const newRects = replaceOverlapingRects(noContainedRects);
@@ -61,7 +63,8 @@ export function getClientRectsNoOverlap(
61
63
  function mergeTouchingRects(
62
64
  rects: Rect[],
63
65
  tolerance: number,
64
- doNotMergeHorizontallyAlignedRects: boolean
66
+ doNotMergeHorizontallyAlignedRects: boolean,
67
+ doNotMergeVerticallyAlignedRects: boolean = false
65
68
  ): Rect[] {
66
69
  for (let i = 0; i < rects.length; i++) {
67
70
  for (let j = i + 1; j < rects.length; j++) {
@@ -78,8 +81,9 @@ function mergeTouchingRects(
78
81
  almostEqual(rect1.left, rect2.left, tolerance) &&
79
82
  almostEqual(rect1.right, rect2.right, tolerance);
80
83
  const horizontalAllowed = !doNotMergeHorizontallyAlignedRects;
84
+ const verticalAllowed = !doNotMergeVerticallyAlignedRects;
81
85
  const aligned =
82
- (rectsLineUpHorizontally && horizontalAllowed) ||
86
+ (rectsLineUpHorizontally && horizontalAllowed && verticalAllowed) ||
83
87
  (rectsLineUpVertically && !rectsLineUpHorizontally);
84
88
  const canMerge = aligned && rectsTouchOrOverlap(rect1, rect2, tolerance);
85
89
  if (canMerge) {
@@ -94,7 +98,8 @@ function mergeTouchingRects(
94
98
  return mergeTouchingRects(
95
99
  newRects,
96
100
  tolerance,
97
- doNotMergeHorizontallyAlignedRects
101
+ doNotMergeHorizontallyAlignedRects,
102
+ doNotMergeVerticallyAlignedRects
98
103
  );
99
104
  }
100
105
  }
@@ -0,0 +1,143 @@
1
+ /*!
2
+ * (℠)
3
+ * # sML.js | I'm a Simple and Middling Library.
4
+ *
5
+ * * Copyright (c) Satoru MATSUSHIMA - https://github.com/satorumurmur/sML
6
+ * * Licensed under the MIT license. - http://www.opensource.org/licenses/mit-license.php
7
+ *
8
+ * Portions of this code come from the sML library
9
+ * Current version: 1.0.36
10
+ */
11
+ /// <reference types="user-agent-data-types" />
12
+
13
+ declare interface OSFlags {
14
+ iOS: number[];
15
+ macOS: number[];
16
+ iPadOS: number[];
17
+ WindowsPhone: number[];
18
+ ChromeOS: number[];
19
+ Windows: number[];
20
+ Android: number[];
21
+ Linux: number[];
22
+ Firefox: boolean;
23
+ }
24
+
25
+ declare interface UAFlags {
26
+ Gecko: number[];
27
+ Firefox: number[];
28
+ Waterfox: number[];
29
+ Opera: number[];
30
+ Silk: number[];
31
+ Blink: number[];
32
+ EdgeHTML: number[];
33
+ Chrome: number[];
34
+ Chromium: number[];
35
+ Phoebe: number[];
36
+ UCBrowser: number[];
37
+ Vivaldi: number[];
38
+ Safari: number[];
39
+ Edge: number[];
40
+ WebKit: number[];
41
+ Trident: number[];
42
+ InternetExplorer: number[];
43
+ Flash: number[];
44
+ Facebook: number[];
45
+ LINE: number[];
46
+ }
47
+
48
+ declare type iOSRequest = "mobile" | "desktop" | undefined;
49
+
50
+ // Fallback when global 'navigator' is not available, such as in SSR environments.
51
+ const userAgent = () => typeof navigator === "undefined" ? "" : (navigator.userAgent || "");
52
+ const userAgentData = () => typeof navigator === "undefined" ? undefined : (navigator.userAgentData || undefined);
53
+
54
+ class sMLFactory {
55
+ OS: OSFlags;
56
+ UA: UAFlags;
57
+ Env!: string[];
58
+
59
+ constructor() {
60
+ const NUAD = userAgentData(), NUA = userAgent();
61
+
62
+ const _sV = (V?: string | number) => (typeof V === "string" || typeof V === "number") && V ? String(V).replace(/_/g, ".").split(".").map(I => parseInt(I) || 0) : [];
63
+ const _dV = (Pre="") => {
64
+ if(!Pre) return [];
65
+ const RE = new RegExp("^.*" + Pre + "[ :\\/]?(\\d+([\\._]\\d+)*).*$");
66
+ if(!RE.test(NUA)) return [];
67
+ return _sV(NUA.replace(RE, "$1"));
68
+ };
69
+
70
+ this.OS = ((OS: OSFlags) => {
71
+ if( /(macOS|Mac OS X)/.test(NUA)) {
72
+ if(/\(iP(hone|od touch);/.test(NUA)) OS.iOS = _dV("CPU (?:iPhone )?OS ");
73
+ if( /\(iPad;/.test(NUA)) OS.iOS = OS.iPadOS = _dV("CPU (?:iPhone )?OS ");
74
+ else if( /(macOS|Mac OS X) \d/.test(NUA)) document.ontouchend !== undefined ? OS.iOS = OS.iPadOS = _dV() : OS.macOS = _dV("(?:macOS|Mac OS X) ");
75
+ } else if( /Windows( NT)? \d/.test(NUA)) OS.Windows = (V => V[0] !== 6 || !V[1] ? V : V[1] === 1 ? [7] : V[1] === 2 ? [8] : [8, 1])(_dV("Windows(?: NT)?"));
76
+ else if( /Android \d/.test(NUA)) OS.Android = _dV("Android");
77
+ else if( /CrOS/.test(NUA)) OS.ChromeOS = _dV();
78
+ else if( /X11;/.test(NUA)) OS.Linux = _dV();
79
+ return OS;
80
+ })({} as OSFlags); if(NUAD) NUAD.getHighEntropyValues(["architecture", "model", "platform", "platformVersion", "uaFullVersion"]).then((HEUAD: any) => (OS => { const Pf = HEUAD.platform, PfV = HEUAD.platformVersion; if(!Pf || !PfV) return;
81
+ if( /^i(OS|P(hone|od touch))$/.test(Pf)) OS.iOS = _sV(PfV);
82
+ else if( /^iPad(OS)?$/.test(Pf)) OS.iOS = OS.iPadOS = _sV(PfV);
83
+ else if(/^(macOS|(Mac )?OS X|Mac(Intel)?)$/.test(Pf)) document.ontouchend !== undefined ? OS.iOS = OS.iPadOS = _sV() : OS.macOS = _sV(PfV);
84
+ else if( /^(Microsoft )?Windows$/.test(Pf)) OS.Windows = _sV(PfV);
85
+ else if( /^(Google )?Android$/.test(Pf)) OS.Android = _sV(PfV);
86
+ else if( /^((Google )?Chrome OS|CrOS)$/.test(Pf)) OS.ChromeOS = _sV(PfV);
87
+ else if( /^(Linux|Ubuntu|X11)$/.test(Pf)) OS.Linux = _sV(PfV);
88
+ else return; /**/ Object.keys(this.OS).forEach(Key => delete (this.OS as any)[Key]), Object.assign(this.OS, OS);
89
+ })({} as OSFlags));
90
+
91
+ this.UA = ((UA: UAFlags) => { let _OK = false;
92
+ if(NUAD && Array.isArray(NUAD.brands)) { const BnV = NUAD.brands.reduce((BnV: Record<string, number[]>, _: NavigatorUABrandVersion) => { BnV[_.brand] = [(_.version as any) * 1]; return BnV; }, {});
93
+ if(BnV["Google Chrome"]) _OK = true, UA.Blink = UA.Chromium = BnV["Chromium"] || [], UA.Chrome = BnV["Google Chrome"];
94
+ else if(BnV["Microsoft Edge"]) _OK = true, UA.Blink = UA.Chromium = BnV["Chromium"] || [], UA.Edge = BnV["Microsoft Edge"];
95
+ else if(BnV["Opera"]) _OK = true, UA.Blink = UA.Chromium = BnV["Chromium"] || [], UA.Opera = BnV["Opera"];
96
+ } if(!_OK) {
97
+ if( / Gecko\/\d/.test(NUA)) { UA.Gecko = _dV("rv");
98
+ if( / Waterfox\/\d/.test(NUA)) UA.Waterfox = _dV("Waterfox");
99
+ else if( / Firefox\/\d/.test(NUA)) UA.Firefox = _dV("Firefox");
100
+ } else if( / Edge\/\d/.test(NUA)) { UA.EdgeHTML = _dV("Edge");
101
+ UA.Edge = UA.EdgeHTML;
102
+ } else if(/ Chrom(ium|e)\/\d/.test(NUA)) { UA.Blink = UA.Chromium = (V => V[0] ? V : _dV("Chrome"))(_dV("Chromium"));
103
+ if( / EdgA?\/\d/.test(NUA)) UA.Edge = (V => V[0] ? V : _dV("Edg"))(_dV("EdgA"));
104
+ else if( / OPR\/\d/.test(NUA)) UA.Opera = _dV("OPR");
105
+ else if( / Vivaldi\/\d/.test(NUA)) UA.Vivaldi = _dV("Vivaldi");
106
+ else if( / Silk\/\d/.test(NUA)) UA.Silk = _dV("Silk");
107
+ else if( / UCBrowser\/\d/.test(NUA)) UA.UCBrowser = _dV("UCBrowser");
108
+ else if( / Phoebe\/\d/.test(NUA)) UA.Phoebe = _dV("Phoebe");
109
+ else UA.Chrome = (V => V[0] ? V : UA.Chromium)(_dV("Chrome"));
110
+ } else if( / AppleWebKit\/\d/.test(NUA)) { UA.WebKit = _dV("AppleWebKit");
111
+ if( / CriOS \d/.test(NUA)) UA.Chrome = _dV("CriOS");
112
+ else if( / FxiOS \d/.test(NUA)) UA.Firefox = _dV("FxiOS");
113
+ else if( / EdgiOS\/\d/.test(NUA)) UA.Edge = _dV("EdgiOS");
114
+ else if( / Version\/\d/.test(NUA)) UA.Safari = _dV("Version");
115
+ } else if( / Trident\/\d/.test(NUA)) { UA.Trident = _dV("Trident");
116
+ UA.InternetExplorer = (V => V[0] ? V : _dV("MSIE"))(_dV("rv"));
117
+ }
118
+ } /*+*/ if( /[\[; ]FB(AN|_IAB)\//.test(NUA)) UA.Facebook = _dV("FBAV");
119
+ /*+*/ if( / Line\/\d/.test(NUA)) UA.LINE = _dV("Line");
120
+ return UA;
121
+ })({} as UAFlags);
122
+
123
+ (this.Env as any) = { get: () => [this.OS, this.UA].reduce((Env: string[], OS_UA) => { for(const Par in OS_UA) if((OS_UA as any)[Par]) Env.push(Par); return Env; }, []) };
124
+ }
125
+ }
126
+
127
+ class sMLFactoryWithRequest extends sMLFactory {
128
+ get iOSRequest(): iOSRequest {
129
+ const NUAD = userAgentData(), NUA = userAgent();
130
+
131
+ if (this.OS.iOS && !this.OS.iPadOS) {
132
+ return "mobile";
133
+ } else if (this.OS.iPadOS) {
134
+ return (/\(iPad;/.test(NUA) || (NUAD && /^iPad(OS)?$/.test(NUAD.platform))) ? "mobile" : "desktop"
135
+ }
136
+
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ const sML = new sMLFactory();
142
+ const sMLWithRequest = new sMLFactoryWithRequest();
143
+ export { sML, sMLWithRequest };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Elements a decoration template is allowed to contain.
3
+ * Allowlisting instead of denylisting: anything not listed here is stripped,
4
+ * including elements that do not yet exist in the HTML spec.
5
+ */
6
+ const ALLOWED_ELEMENTS = [
7
+ // Structure / presentation
8
+ "div", "span", "p", "br", "hr",
9
+ "b", "i", "em", "strong", "s", "u", "mark", "small", "sub", "sup",
10
+ "abbr", "cite", "code", "data", "dfn", "kbd", "q", "samp", "time", "var",
11
+ "blockquote", "pre",
12
+ // SVG — useful for icon-style decorations (e.g. sidemarks)
13
+ "svg", "g", "path", "circle", "ellipse", "rect", "line",
14
+ "polygon", "polyline", "text", "tspan", "defs", "use",
15
+ ];
16
+
17
+ /** Attributes that introduce executable code on any element. */
18
+ const DANGEROUS_ATTR = /^on/i;
19
+ /** Attributes that carry URLs and must be checked for unsafe schemes. */
20
+ const URL_ATTRS = new Set(["href", "src", "action", "formaction", "xlink:href"]);
21
+ /** URL schemes that must not appear in URL-bearing attributes. */
22
+ const DANGEROUS_SCHEME = /^\s*(javascript|data):/i;
23
+
24
+ /**
25
+ * Parses `html` and returns its first element child with all executable
26
+ * content removed. Uses the Sanitizer API when available, falls back to a
27
+ * manual DOMParser scrub otherwise.
28
+ *
29
+ * The allowlist approach is intentional: unknown or future elements are
30
+ * stripped by default rather than permitted by oversight.
31
+ *
32
+ * @param wnd Window whose document is used when adopting nodes.
33
+ * @param html Raw HTML string supplied by the caller.
34
+ * @returns The sanitized first element child, or `null` for empty input.
35
+ */
36
+ export function sanitizeHTML(wnd: Window, html: string): Element | null {
37
+ const host = wnd.document.createElement("div");
38
+
39
+ if ("Sanitizer" in wnd && typeof (host as any).setHTML === "function") {
40
+ try {
41
+ const sanitizer = new (wnd as any).Sanitizer({ allowElements: ALLOWED_ELEMENTS });
42
+ (host as any).setHTML(html, { sanitizer });
43
+ return host.firstElementChild as Element | null;
44
+ } catch {
45
+ // Sanitizer API present but call failed — fall through to DOMParser.
46
+ }
47
+ }
48
+
49
+ // DOMParser fallback: parse in an isolated document then scrub manually.
50
+ const scratch = wnd.document.implementation.createHTMLDocument("");
51
+ scratch.body.innerHTML = html;
52
+ scrubNode(scratch.body, new Set(ALLOWED_ELEMENTS));
53
+ while (scratch.body.firstChild) {
54
+ host.appendChild(wnd.document.adoptNode(scratch.body.firstChild));
55
+ }
56
+ return host.firstElementChild as Element | null;
57
+ }
58
+
59
+ function scrubNode(root: Element, allowed: Set<string>): void {
60
+ // Walk in reverse so removals don't shift indices.
61
+ const all = Array.from(root.querySelectorAll("*")).reverse();
62
+ for (const el of all) {
63
+ if (!allowed.has(el.localName)) {
64
+ // Replace disallowed element with its children to preserve text.
65
+ el.replaceWith(...Array.from(el.childNodes));
66
+ continue;
67
+ }
68
+ for (const { name, value } of Array.from(el.attributes)) {
69
+ if (
70
+ DANGEROUS_ATTR.test(name) ||
71
+ (URL_ATTRS.has(name) && DANGEROUS_SCHEME.test(value))
72
+ ) {
73
+ el.removeAttribute(name);
74
+ }
75
+ }
76
+ }
77
+ }
package/src/index.ts CHANGED
@@ -2,4 +2,5 @@ export * from './comms/index.ts';
2
2
  export * from './modules/index.ts';
3
3
  export * from './Loader.ts';
4
4
  export * from './protection/index.ts';
5
- export * from './keyboard/index.ts'
5
+ export * from './helpers/sML.ts';
6
+ export * from './keyboard/index.ts'
@@ -146,6 +146,7 @@ export class KeyCombinationManager {
146
146
  const handlers = this.createKeyboardHandlers(targetFrameSrc, shortcuts, dispatcher, wnd);
147
147
 
148
148
  return (event: KeyboardEvent) => {
149
+ if (!event.isTrusted) return;
149
150
  for (const handlerConfig of handlers) {
150
151
  if (this.match(event, [handlerConfig])) {
151
152
  const suppress = handlerConfig.suppressOnInteractiveElement;