@readium/navigator-html-injectables 2.4.4 → 2.6.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/dist/Loader.js +1 -0
- package/dist/comms/comms.js +1 -0
- package/dist/comms/mid.js +1 -0
- package/dist/helpers/animation.js +1 -0
- package/dist/helpers/css.js +1 -0
- package/dist/helpers/document.js +1 -0
- package/dist/helpers/dom.js +1 -0
- package/dist/helpers/locator.js +1 -0
- package/dist/helpers/rect.js +1 -0
- package/dist/helpers/sanitize.js +1 -0
- package/dist/index.js +1 -4174
- package/dist/keyboard/KeyCombinationManager.js +1 -0
- package/dist/keyboard/KeyboardCombinations.js +1 -0
- package/dist/modules/Decorator.js +24 -0
- package/dist/modules/Module.js +1 -0
- package/dist/modules/ModuleLibrary.js +1 -0
- package/dist/modules/Peripherals.js +1 -0
- package/dist/modules/setup/FixedSetup.js +13 -0
- package/dist/modules/setup/ReflowableSetup.js +1 -0
- package/dist/modules/setup/Setup.js +1 -0
- package/dist/modules/setup/WebPubSetup.js +1 -0
- package/dist/modules/snapper/CJKVerticalSnapper.js +12 -0
- package/dist/modules/snapper/ColumnSnapper.js +35 -0
- package/dist/modules/snapper/ScrollSnapper.js +9 -0
- package/dist/modules/snapper/Snapper.js +5 -0
- package/dist/modules/snapper/WebPubSnapper.js +1 -0
- package/dist/protection/BulkCopyProtector.js +1 -0
- package/dist/protection/PatternAnalyzer.js +1 -0
- package/dist/protection/PrintProtector.js +15 -0
- package/dist/protection/SelectionAnalyzer.js +1 -0
- package/dist/protection/config.js +1 -0
- package/dist/vendor/approx-string-match/index.js +1 -0
- package/dist/vendor/hypothesis/anchoring/match-quote.js +1 -0
- package/dist/vendor/hypothesis/anchoring/text-range.js +1 -0
- package/dist/vendor/hypothesis/anchoring/trim-range.js +1 -0
- package/dist/vendor/hypothesis/anchoring/types.js +1 -0
- package/package.json +9 -7
- package/src/comms/comms.ts +11 -1
- package/src/comms/keys.ts +8 -3
- package/src/helpers/css.ts +30 -1
- package/src/helpers/document.ts +123 -3
- package/src/helpers/locator.ts +5 -5
- package/src/helpers/rect.ts +88 -22
- package/src/helpers/sanitize.ts +77 -0
- package/src/index.ts +2 -1
- package/src/modules/Decorator.ts +924 -178
- package/src/modules/Peripherals.ts +3 -0
- package/src/modules/snapper/CJKVerticalSnapper.ts +34 -7
- package/src/modules/snapper/ColumnSnapper.ts +56 -7
- package/src/modules/snapper/ScrollSnapper.ts +30 -7
- package/src/modules/snapper/Snapper.ts +144 -0
- package/src/modules/snapper/WebPubSnapper.ts +31 -7
- package/types/src/comms/comms.d.ts +10 -1
- package/types/src/comms/keys.d.ts +2 -2
- package/types/src/helpers/css.d.ts +7 -1
- package/types/src/helpers/document.d.ts +69 -3
- package/types/src/helpers/rect.d.ts +2 -1
- package/types/src/helpers/sanitize.d.ts +13 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/modules/Decorator.d.ts +92 -17
- package/types/src/modules/Peripherals.d.ts +3 -0
- package/types/src/modules/snapper/CJKVerticalSnapper.d.ts +1 -0
- package/types/src/modules/snapper/ColumnSnapper.d.ts +14 -1
- package/types/src/modules/snapper/ScrollSnapper.d.ts +1 -0
- package/types/src/modules/snapper/Snapper.d.ts +58 -0
- package/types/src/modules/snapper/WebPubSnapper.d.ts +1 -0
- package/dist/ar-DyHX_uy2.js +0 -7
- package/dist/da-Dct0PS3E.js +0 -7
- package/dist/fr-C5HEel98.js +0 -7
- package/dist/index.umd.cjs +0 -95
- package/dist/it-DFOBoXGy.js +0 -7
- package/dist/pt_PT-Di3sVjze.js +0 -7
- package/dist/sv-BfzAFsVN.js +0 -7
- package/src/helpers/color.ts +0 -205
- package/types/src/helpers/color.d.ts +0 -26
package/src/modules/Decorator.ts
CHANGED
|
@@ -1,54 +1,117 @@
|
|
|
1
1
|
import { Locator } from "@readium/shared";
|
|
2
|
-
import {
|
|
2
|
+
import { IComms } from "../comms/comms.ts";
|
|
3
3
|
import { Module } from "./Module.ts";
|
|
4
4
|
import { rangeFromLocator } from "../helpers/locator.ts";
|
|
5
5
|
import { ModuleName } from "./ModuleLibrary.ts";
|
|
6
|
-
import { Rect, getClientRectsNoOverlap } from "../helpers/rect.ts";
|
|
6
|
+
import { Rect, getClientRectsNoOverlap, getTextClientRects, rectContainsPoint } from "../helpers/rect.ts";
|
|
7
7
|
import { getProperty } from "../helpers/css.ts";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import { isDarkColor, getContrastingTextColor, adjustColorForContrast, colorToRgba } from "@readium/helpers";
|
|
9
|
+
import { makeWritingContext } from "../helpers/document.ts";
|
|
10
|
+
import { sML } from "@readium/helpers";
|
|
11
|
+
import { sanitizeHTML } from "../helpers/sanitize.ts";
|
|
12
|
+
|
|
13
|
+
function defaultTint(type: DecorationStyleType): string {
|
|
14
|
+
switch (type) {
|
|
15
|
+
case DecorationStyleType.Mask:
|
|
16
|
+
return "rgba(255, 255, 255, 0.5)";
|
|
17
|
+
case DecorationStyleType.Highlight:
|
|
18
|
+
case DecorationStyleType.HighlightUnderline:
|
|
19
|
+
return "#FFFF00";
|
|
20
|
+
default:
|
|
21
|
+
return "#FF0000";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
|
|
13
|
-
export
|
|
25
|
+
export const DecorationStyleType = {
|
|
26
|
+
Highlight: "highlight", // Background color overlay.
|
|
27
|
+
HighlightUnderline: "highlightUnderline", // Background color overlay + underline (the converged active state from RFC 008).
|
|
28
|
+
Underline: "underline", // Underline drawn beneath the text.
|
|
29
|
+
Strikethrough: "strikethrough", // Line drawn through the vertical centre of the text.
|
|
30
|
+
Outline: "outline", // Border drawn around the text boxes.
|
|
31
|
+
TextColor: "textColor", // Changes the text color directly.
|
|
32
|
+
Mask: "mask", // Dims everything outside the selection rects. Use width: Page for block-level behaviour.
|
|
33
|
+
Template: "template", // Custom HTML template (HTMLDecorationTemplate).
|
|
34
|
+
} as const;
|
|
35
|
+
export type DecorationStyleType = typeof DecorationStyleType[keyof typeof DecorationStyleType];
|
|
36
|
+
|
|
37
|
+
export enum DecorationWidth {
|
|
14
38
|
Wrap = "wrap", // Smallest width fitting the CSS border box.
|
|
15
39
|
Viewport = "viewport", // Fills the whole viewport.
|
|
16
|
-
Bounds = "bounds", // Fills the
|
|
17
|
-
Page = "page", // Fills the
|
|
40
|
+
Bounds = "bounds", // Fills the bounding region of all CSS border boxes.
|
|
41
|
+
Page = "page", // Fills the anchor page, useful for dual-page layouts.
|
|
18
42
|
}
|
|
19
43
|
|
|
20
|
-
export enum
|
|
44
|
+
export enum DecorationLayout {
|
|
21
45
|
Boxes = "boxes", // One HTML element for each CSS border box (e.g. line of text).
|
|
22
46
|
Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes.
|
|
23
47
|
}
|
|
24
48
|
|
|
25
|
-
|
|
26
|
-
export interface
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
/** Built-in decoration styles. layout/width are optional overrides; defaults are Boxes/Wrap. */
|
|
50
|
+
export interface BuiltinDecorationStyle {
|
|
51
|
+
type?: Exclude<DecorationStyleType, "template">;
|
|
52
|
+
tint?: string;
|
|
53
|
+
layout?: DecorationLayout;
|
|
54
|
+
width?: DecorationWidth;
|
|
55
|
+
enforceContrast?: boolean; // When true (default), tint is adjusted for contrast against the background.
|
|
56
|
+
expand?: number; // Inflates each client rect outward by this many CSS pixels on all sides.
|
|
30
57
|
}
|
|
31
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Custom decoration style backed by caller-supplied HTML.
|
|
61
|
+
* Matches the HTMLDecorationTemplate class from the Readium spec.
|
|
62
|
+
* The element string is sanitized before injection.
|
|
63
|
+
* --readium-tint is injected as a CSS custom property on each created element.
|
|
64
|
+
*/
|
|
65
|
+
export interface HTMLDecorationTemplate {
|
|
66
|
+
type: "template";
|
|
67
|
+
layout: DecorationLayout;
|
|
68
|
+
width: DecorationWidth;
|
|
69
|
+
element: string;
|
|
70
|
+
stylesheet?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
|
|
74
|
+
|
|
32
75
|
export interface Decoration {
|
|
33
76
|
id: string; // Unique ID of the decoration. It must be unique in the group the decoration is applied to.
|
|
34
77
|
locator: Locator; // Location in the publication where the decoration will be rendered.
|
|
35
|
-
style:
|
|
36
|
-
//
|
|
78
|
+
style: DecorationStyle; // Declares the look and feel of the decoration.
|
|
79
|
+
extras?: Record<string, unknown>; // App-specific context data passed through to DecorationActivationEvent.
|
|
37
80
|
}
|
|
38
81
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
82
|
+
interface DecorationEventBase {
|
|
83
|
+
decorationId: string;
|
|
84
|
+
group: string;
|
|
85
|
+
rect?: { top: number; left: number; width: number; height: number };
|
|
86
|
+
point?: { x: number; y: number };
|
|
43
87
|
}
|
|
44
88
|
|
|
89
|
+
export interface DecorationActivatedEvent extends DecorationEventBase {
|
|
90
|
+
rect: { top: number; left: number; width: number; height: number }; // Always present on activation.
|
|
91
|
+
point: { x: number; y: number }; // Always present on activation.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DecorationPointerEnterData extends DecorationEventBase {
|
|
95
|
+
rect: { top: number; left: number; width: number; height: number }; // Always present on enter.
|
|
96
|
+
point: { x: number; y: number }; // Always present on enter.
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type DecorationPointerLeaveData = DecorationEventBase;
|
|
100
|
+
|
|
101
|
+
export type DecoratorRequest =
|
|
102
|
+
| { group: string; action: "add" | "update"; decoration: Decoration }
|
|
103
|
+
| { group: string; action: "remove"; decoration: Pick<Decoration, "id"> }
|
|
104
|
+
| { group: string; action: "clear" };
|
|
105
|
+
|
|
45
106
|
interface DecorationItem {
|
|
46
107
|
id: string;
|
|
47
108
|
decoration: Decoration;
|
|
48
109
|
range: Range;
|
|
49
|
-
|
|
110
|
+
hitRects: Rect[]; // Merged client rects for hit testing; refreshed after each layout.
|
|
50
111
|
clickableElements: HTMLElement[] | undefined;
|
|
51
112
|
container: HTMLElement | undefined;
|
|
113
|
+
highlightSubKey?: string; // CSS.highlights key shared by all items with the same type+tint.
|
|
114
|
+
highlightCSS?: string; // The ::highlight() rule for this item's tint group.
|
|
52
115
|
}
|
|
53
116
|
|
|
54
117
|
const canNativeHighlight = () => ("Highlight" in window);
|
|
@@ -58,9 +121,18 @@ class DecorationGroup {
|
|
|
58
121
|
public readonly items: DecorationItem[] = [];
|
|
59
122
|
private lastItemId = 0;
|
|
60
123
|
private container: HTMLDivElement | undefined = undefined;
|
|
61
|
-
private
|
|
124
|
+
private _activatable = false;
|
|
125
|
+
private _hoverable = false;
|
|
126
|
+
private hoveredItem: DecorationItem | undefined = undefined;
|
|
62
127
|
public readonly experimentalHighlights: boolean = false;
|
|
63
128
|
private readonly notTextFlag: Map<string, boolean> | undefined;
|
|
129
|
+
private readonly _tintSubKeys = new Map<string, string>(); // (type::adjustedTint) → subKey
|
|
130
|
+
private _subKeyCounter = 0;
|
|
131
|
+
private readonly activationHandler: (e: PointerEvent) => void;
|
|
132
|
+
private readonly hoverHandler: (e: PointerEvent) => void;
|
|
133
|
+
private maskSvg: SVGSVGElement | undefined = undefined;
|
|
134
|
+
private shadowHost: HTMLDivElement | undefined = undefined;
|
|
135
|
+
private shadowRoot: ShadowRoot | undefined = undefined;
|
|
64
136
|
|
|
65
137
|
/**
|
|
66
138
|
* Creates a DecorationGroup object
|
|
@@ -68,8 +140,8 @@ class DecorationGroup {
|
|
|
68
140
|
* @param name Human-readable name of the group
|
|
69
141
|
*/
|
|
70
142
|
constructor(
|
|
71
|
-
private readonly wnd:
|
|
72
|
-
private readonly comms:
|
|
143
|
+
private readonly wnd: Window,
|
|
144
|
+
private readonly comms: IComms,
|
|
73
145
|
private readonly id: string,
|
|
74
146
|
private readonly name: string
|
|
75
147
|
) {
|
|
@@ -77,14 +149,41 @@ class DecorationGroup {
|
|
|
77
149
|
this.experimentalHighlights = true;
|
|
78
150
|
this.notTextFlag = new Map<string, boolean>();
|
|
79
151
|
}
|
|
152
|
+
this.activationHandler = this.handleActivation.bind(this);
|
|
153
|
+
this.wnd.document.addEventListener("pointerup", this.activationHandler);
|
|
154
|
+
this.hoverHandler = this.handleHover.bind(this);
|
|
155
|
+
this.wnd.document.addEventListener("pointermove", this.hoverHandler);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get activatable() {
|
|
159
|
+
return this._activatable;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
set activatable(value: boolean) {
|
|
163
|
+
this._activatable = value;
|
|
80
164
|
}
|
|
81
165
|
|
|
82
|
-
get
|
|
83
|
-
return this.
|
|
166
|
+
get hoverable() {
|
|
167
|
+
return this._hoverable;
|
|
84
168
|
}
|
|
85
169
|
|
|
86
|
-
set
|
|
87
|
-
this.
|
|
170
|
+
set hoverable(value: boolean) {
|
|
171
|
+
this._hoverable = value;
|
|
172
|
+
if (!value && this.hoveredItem) {
|
|
173
|
+
const leaveRect = this.hoveredItem.range.getBoundingClientRect();
|
|
174
|
+
const pixelRatio = this.wnd.devicePixelRatio;
|
|
175
|
+
this.comms.send("decoration_pointer_leave", {
|
|
176
|
+
decorationId: this.hoveredItem.decoration.id,
|
|
177
|
+
group: this.name,
|
|
178
|
+
rect: {
|
|
179
|
+
top: leaveRect.top * pixelRatio,
|
|
180
|
+
left: leaveRect.left * pixelRatio,
|
|
181
|
+
width: leaveRect.width * pixelRatio,
|
|
182
|
+
height: leaveRect.height * pixelRatio,
|
|
183
|
+
},
|
|
184
|
+
} as DecorationPointerLeaveData);
|
|
185
|
+
this.hoveredItem = undefined;
|
|
186
|
+
}
|
|
88
187
|
}
|
|
89
188
|
|
|
90
189
|
/**
|
|
@@ -116,15 +215,49 @@ class DecorationGroup {
|
|
|
116
215
|
this.notTextFlag?.set(id, true);
|
|
117
216
|
}
|
|
118
217
|
}
|
|
218
|
+
// Walk up from both ends of the range to detect inline SVG ancestry (namespace check
|
|
219
|
+
// catches <text> inside <svg> which tag-name checks above would miss).
|
|
220
|
+
if(this.experimentalHighlights && !this.notTextFlag?.has(id)) {
|
|
221
|
+
const hasSvgAncestor = (node: Node | null): boolean => {
|
|
222
|
+
while (node && node.nodeType === Node.ELEMENT_NODE) {
|
|
223
|
+
if ((node as Element).namespaceURI?.includes("svg")) return true;
|
|
224
|
+
node = node.parentNode;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
};
|
|
228
|
+
if (hasSvgAncestor(range.startContainer) || hasSvgAncestor(range.endContainer)) {
|
|
229
|
+
this.notTextFlag?.set(id, true);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (this.experimentalHighlights) {
|
|
233
|
+
const { type } = decoration.style;
|
|
234
|
+
const { layout, width, expand } = decoration.style as BuiltinDecorationStyle;
|
|
235
|
+
// CSS Highlight API only handles text-level highlight styling (boxes + wrap).
|
|
236
|
+
// Everything else must go through the DOM overlay path.
|
|
237
|
+
const needsDomOverlay =
|
|
238
|
+
type !== DecorationStyleType.TextColor && (
|
|
239
|
+
type === DecorationStyleType.Outline ||
|
|
240
|
+
type === DecorationStyleType.Template ||
|
|
241
|
+
type === DecorationStyleType.Mask ||
|
|
242
|
+
(layout !== undefined && layout !== DecorationLayout.Boxes) ||
|
|
243
|
+
(width !== undefined && width !== DecorationWidth.Wrap) ||
|
|
244
|
+
!!expand
|
|
245
|
+
);
|
|
246
|
+
if (needsDomOverlay) this.notTextFlag?.set(id, true);
|
|
247
|
+
}
|
|
119
248
|
|
|
120
249
|
const item = {
|
|
121
250
|
decoration,
|
|
122
251
|
id,
|
|
123
252
|
range,
|
|
253
|
+
hitRects: [],
|
|
254
|
+
clickableElements: undefined,
|
|
255
|
+
container: undefined,
|
|
124
256
|
} as DecorationItem;
|
|
125
257
|
|
|
126
258
|
this.items.push(item);
|
|
127
259
|
this.layout(item);
|
|
260
|
+
item.hitRects = this.clientRectsToDocCoords(getClientRectsNoOverlap(item.range, false, false, ((item.decoration.style as BuiltinDecorationStyle).expand ?? 0) + this.hitGap()));
|
|
128
261
|
this.renderLayout([item]);
|
|
129
262
|
}
|
|
130
263
|
|
|
@@ -137,18 +270,32 @@ class DecorationGroup {
|
|
|
137
270
|
if (index < 0) return;
|
|
138
271
|
|
|
139
272
|
const item = this.items[index];
|
|
273
|
+
const wasMask = item.decoration.style?.type === DecorationStyleType.Mask;
|
|
274
|
+
|
|
140
275
|
this.items.splice(index, 1);
|
|
141
276
|
item.clickableElements = undefined;
|
|
142
277
|
if (item.container) {
|
|
143
278
|
item.container.remove();
|
|
144
279
|
item.container = undefined;
|
|
145
280
|
}
|
|
146
|
-
if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
281
|
+
if (this.experimentalHighlights && !this.notTextFlag?.has(item.id) && item.highlightSubKey) {
|
|
282
|
+
const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, any>;
|
|
283
|
+
cssHighlights.get(item.highlightSubKey)?.delete(item.range);
|
|
284
|
+
if (!this.items.some(i => i.highlightSubKey === item.highlightSubKey)) {
|
|
285
|
+
cssHighlights.delete(item.highlightSubKey);
|
|
286
|
+
}
|
|
287
|
+
const stylesheet = this.wnd.document.getElementById(`${this.id}-style`) as HTMLStyleElement | null;
|
|
288
|
+
if (stylesheet) this._rebuildHighlightStylesheet(stylesheet);
|
|
150
289
|
}
|
|
151
290
|
this.notTextFlag?.delete(item.id);
|
|
291
|
+
if (this.hoveredItem === item) {
|
|
292
|
+
this.hoveredItem = undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Update shared mask if we removed a mask decoration
|
|
296
|
+
if (wasMask) {
|
|
297
|
+
this.updateSharedMask();
|
|
298
|
+
}
|
|
152
299
|
}
|
|
153
300
|
|
|
154
301
|
/**
|
|
@@ -167,6 +314,167 @@ class DecorationGroup {
|
|
|
167
314
|
this.clearContainer();
|
|
168
315
|
this.items.length = 0;
|
|
169
316
|
this.notTextFlag?.clear();
|
|
317
|
+
this.hoveredItem = undefined;
|
|
318
|
+
// Clear shared mask
|
|
319
|
+
if (this.maskSvg) {
|
|
320
|
+
this.maskSvg.remove();
|
|
321
|
+
this.maskSvg = undefined;
|
|
322
|
+
}
|
|
323
|
+
if (this.shadowHost) {
|
|
324
|
+
this.shadowHost.remove();
|
|
325
|
+
this.shadowHost = undefined;
|
|
326
|
+
this.shadowRoot = undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Removes all decorations and tears down event listeners.
|
|
332
|
+
* Must be called when the group is permanently discarded.
|
|
333
|
+
*/
|
|
334
|
+
destroy() {
|
|
335
|
+
this.clear();
|
|
336
|
+
this.wnd.document.removeEventListener("pointerup", this.activationHandler);
|
|
337
|
+
this.wnd.document.removeEventListener("pointermove", this.hoverHandler);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private clientRectsToDocCoords(rects: Rect[]): Rect[] {
|
|
341
|
+
const ctx = makeWritingContext(this.wnd);
|
|
342
|
+
const dx = ctx.xDocOffset;
|
|
343
|
+
const dy = ctx.yDocOffset;
|
|
344
|
+
if (dx === 0 && dy === 0) return rects;
|
|
345
|
+
return rects.map(r => ({
|
|
346
|
+
left: r.left + dx, top: r.top + dy,
|
|
347
|
+
right: r.right + dx, bottom: r.bottom + dy,
|
|
348
|
+
width: r.width, height: r.height,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private pointerToDocCoords(e: PointerEvent): { docX: number; docY: number } {
|
|
353
|
+
const ctx = makeWritingContext(this.wnd);
|
|
354
|
+
return { docX: e.clientX + ctx.xDocOffset, docY: e.clientY + ctx.yDocOffset };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private effectiveZoom(): number {
|
|
358
|
+
if (!sML.UA.Blink) return 1;
|
|
359
|
+
const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
|
|
360
|
+
const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
|
|
361
|
+
return (rootZoom || 1) * (bodyZoom || 1);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private hitGap(): number {
|
|
365
|
+
return 2 * this.effectiveZoom();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private handleActivation(e: PointerEvent) {
|
|
369
|
+
if (!this._activatable) return;
|
|
370
|
+
const { docX, docY } = this.pointerToDocCoords(e);
|
|
371
|
+
const pixelRatio = this.wnd.devicePixelRatio;
|
|
372
|
+
|
|
373
|
+
for (const item of this.items) {
|
|
374
|
+
let hitRect: DOMRect | undefined;
|
|
375
|
+
|
|
376
|
+
if (item.decoration.style.type === DecorationStyleType.Template) {
|
|
377
|
+
// Templates can be positioned anywhere (e.g. a margin sidemark), so hit-test
|
|
378
|
+
// against the rendered elements rather than the text range rects.
|
|
379
|
+
for (const el of (item.clickableElements ?? [])) {
|
|
380
|
+
const r = el.getBoundingClientRect();
|
|
381
|
+
if (rectContainsPoint(r as Rect, e.clientX, e.clientY, 0)) {
|
|
382
|
+
hitRect = r;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
// Use pre-merged hit rects stored in document coordinates so they remain
|
|
388
|
+
// valid across column/scroll navigation.
|
|
389
|
+
for (const rect of item.hitRects) {
|
|
390
|
+
if (rectContainsPoint(rect, docX, docY, 0)) {
|
|
391
|
+
hitRect = item.range.getBoundingClientRect();
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (hitRect) {
|
|
398
|
+
this.comms.send("decoration_activated", {
|
|
399
|
+
decorationId: item.decoration.id,
|
|
400
|
+
group: this.name,
|
|
401
|
+
rect: {
|
|
402
|
+
top: hitRect.top * pixelRatio,
|
|
403
|
+
left: hitRect.left * pixelRatio,
|
|
404
|
+
width: hitRect.width * pixelRatio,
|
|
405
|
+
height: hitRect.height * pixelRatio,
|
|
406
|
+
},
|
|
407
|
+
point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
|
|
408
|
+
} as DecorationActivatedEvent);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private handleHover(e: PointerEvent) {
|
|
415
|
+
if (!this._hoverable) return;
|
|
416
|
+
const { docX, docY } = this.pointerToDocCoords(e);
|
|
417
|
+
const pixelRatio = this.wnd.devicePixelRatio;
|
|
418
|
+
|
|
419
|
+
let hitItem: DecorationItem | undefined;
|
|
420
|
+
let hitRect: DOMRect | undefined;
|
|
421
|
+
|
|
422
|
+
for (const item of this.items) {
|
|
423
|
+
if (item.decoration.style.type === DecorationStyleType.Template) {
|
|
424
|
+
for (const el of (item.clickableElements ?? [])) {
|
|
425
|
+
const r = el.getBoundingClientRect();
|
|
426
|
+
if (rectContainsPoint(r as Rect, e.clientX, e.clientY, 0)) {
|
|
427
|
+
hitItem = item;
|
|
428
|
+
hitRect = r;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
for (const rect of item.hitRects) {
|
|
434
|
+
if (rectContainsPoint(rect, docX, docY, 0)) {
|
|
435
|
+
hitItem = item;
|
|
436
|
+
hitRect = item.range.getBoundingClientRect();
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (hitItem) break;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (hitItem === this.hoveredItem) return;
|
|
446
|
+
|
|
447
|
+
if (this.hoveredItem) {
|
|
448
|
+
const connected = this.hoveredItem.range.commonAncestorContainer.isConnected;
|
|
449
|
+
const leaveRect = connected ? this.hoveredItem.range.getBoundingClientRect() : null;
|
|
450
|
+
this.comms.send("decoration_pointer_leave", {
|
|
451
|
+
decorationId: this.hoveredItem.decoration.id,
|
|
452
|
+
group: this.name,
|
|
453
|
+
rect: leaveRect ? {
|
|
454
|
+
top: leaveRect.top * pixelRatio,
|
|
455
|
+
left: leaveRect.left * pixelRatio,
|
|
456
|
+
width: leaveRect.width * pixelRatio,
|
|
457
|
+
height: leaveRect.height * pixelRatio,
|
|
458
|
+
} : undefined,
|
|
459
|
+
point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
|
|
460
|
+
} as DecorationPointerLeaveData);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.hoveredItem = hitItem;
|
|
464
|
+
|
|
465
|
+
if (hitItem && hitRect) {
|
|
466
|
+
this.comms.send("decoration_pointer_enter", {
|
|
467
|
+
decorationId: hitItem.decoration.id,
|
|
468
|
+
group: this.name,
|
|
469
|
+
rect: {
|
|
470
|
+
top: hitRect.top * pixelRatio,
|
|
471
|
+
left: hitRect.left * pixelRatio,
|
|
472
|
+
width: hitRect.width * pixelRatio,
|
|
473
|
+
height: hitRect.height * pixelRatio,
|
|
474
|
+
},
|
|
475
|
+
point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
|
|
476
|
+
} as DecorationPointerEnterData);
|
|
477
|
+
}
|
|
170
478
|
}
|
|
171
479
|
|
|
172
480
|
/**
|
|
@@ -176,24 +484,175 @@ class DecorationGroup {
|
|
|
176
484
|
requestLayout() {
|
|
177
485
|
this.wnd.cancelAnimationFrame(this.currentRender);
|
|
178
486
|
this.clearContainer();
|
|
179
|
-
|
|
180
|
-
|
|
487
|
+
// Wait for fonts to finish loading before reading geometry, then use a
|
|
488
|
+
// rAF to ensure the browser has finished reflowing with the new metrics.
|
|
489
|
+
// Without this, font-family / zoom changes cause positions to be read
|
|
490
|
+
// against stale or fallback-font layout.
|
|
491
|
+
this.wnd.document.fonts.ready.then(() => {
|
|
492
|
+
this.currentRender = this.wnd.requestAnimationFrame(() => {
|
|
493
|
+
this.items.forEach(i => {
|
|
494
|
+
this.layout(i);
|
|
495
|
+
i.hitRects = this.clientRectsToDocCoords(getClientRectsNoOverlap(i.range, false, false, ((i.decoration.style as BuiltinDecorationStyle).expand ?? 0) + this.hitGap()));
|
|
496
|
+
});
|
|
497
|
+
this.renderLayout(this.items);
|
|
498
|
+
// Update shared mask after layout
|
|
499
|
+
this.updateSharedMask();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
181
502
|
}
|
|
182
503
|
|
|
183
504
|
private experimentalLayout(item: DecorationItem) {
|
|
184
|
-
const
|
|
185
|
-
|
|
505
|
+
const stylesheet = this.requireContainer(true) as HTMLStyleElement;
|
|
506
|
+
const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, any>;
|
|
507
|
+
|
|
508
|
+
// Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here.
|
|
509
|
+
const style = item.decoration.style as BuiltinDecorationStyle;
|
|
510
|
+
const type = style.type ?? DecorationStyleType.Highlight;
|
|
511
|
+
const tint = style.tint ?? defaultTint(type);
|
|
512
|
+
const width = style.width;
|
|
513
|
+
const layout = style.layout;
|
|
514
|
+
|
|
515
|
+
// Group by (type, tint) — items sharing the same visual style share one sub-highlight and one CSS rule.
|
|
516
|
+
const subKey = this._getSubKey(type, tint);
|
|
517
|
+
|
|
518
|
+
// On update: remove this item's range from its previous sub-highlight.
|
|
519
|
+
if (item.highlightSubKey) {
|
|
520
|
+
const oldSub = cssHighlights.get(item.highlightSubKey) as any;
|
|
521
|
+
oldSub?.delete(item.range);
|
|
522
|
+
if (item.highlightSubKey !== subKey &&
|
|
523
|
+
!this.items.some(i => i !== item && i.highlightSubKey === item.highlightSubKey)) {
|
|
524
|
+
cssHighlights.delete(item.highlightSubKey);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
item.highlightSubKey = subKey;
|
|
528
|
+
|
|
529
|
+
// Get or create the shared sub-highlight for this tint group.
|
|
530
|
+
let sub: any;
|
|
531
|
+
if (cssHighlights.has(subKey)) {
|
|
532
|
+
sub = cssHighlights.get(subKey);
|
|
533
|
+
} else {
|
|
534
|
+
sub = new (this.wnd as any).Highlight();
|
|
535
|
+
cssHighlights.set(subKey, sub);
|
|
536
|
+
}
|
|
186
537
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
538
|
+
// Helper for caret position
|
|
539
|
+
const caretPositionFromPoint = (x: number, y: number): CaretPosition | null => {
|
|
540
|
+
return this.wnd.document.caretPositionFromPoint?.(x, y) ?? null;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// TextColor range registration: expand to bounding rect when layout/width asks for it.
|
|
544
|
+
if (
|
|
545
|
+
type === DecorationStyleType.TextColor &&
|
|
546
|
+
(layout === DecorationLayout.Bounds || width === DecorationWidth.Bounds || width === DecorationWidth.Page)
|
|
547
|
+
) {
|
|
548
|
+
// For vertical writing, caretPositionFromPoint has browser bugs - use fallback
|
|
549
|
+
const ctx = makeWritingContext(this.wnd);
|
|
550
|
+
if (ctx.isVertical) {
|
|
551
|
+
console.warn('Vertical writing detected: caretPositionFromPoint has known bugs, falling back to original range');
|
|
552
|
+
sub.add(item.range);
|
|
553
|
+
} else {
|
|
554
|
+
const boundingRect = item.range.getBoundingClientRect();
|
|
555
|
+
// Page snaps to the full page inline extent; Bounds/layout:Bounds uses the actual bounding rect.
|
|
556
|
+
let inlineOrigin: number;
|
|
557
|
+
let inlineExtent: number;
|
|
558
|
+
if (width === DecorationWidth.Page) {
|
|
559
|
+
const snap = Math.floor(ctx.inlineStart(boundingRect) / ctx.pageInlineSize) * ctx.pageInlineSize;
|
|
560
|
+
inlineOrigin = snap;
|
|
561
|
+
inlineExtent = ctx.pageInlineSize;
|
|
562
|
+
} else {
|
|
563
|
+
inlineOrigin = ctx.inlineStart(boundingRect);
|
|
564
|
+
inlineExtent = ctx.inlineSize(boundingRect);
|
|
565
|
+
}
|
|
566
|
+
const startCaret = caretPositionFromPoint(inlineOrigin, ctx.blockStart(boundingRect) + 1);
|
|
567
|
+
const endCaret = caretPositionFromPoint(inlineOrigin + inlineExtent, ctx.blockStart(boundingRect) + ctx.blockSize(boundingRect) - 1);
|
|
568
|
+
if (startCaret && endCaret) {
|
|
569
|
+
const expandedRange = this.wnd.document.createRange();
|
|
570
|
+
expandedRange.setStart(startCaret.offsetNode, startCaret.offset);
|
|
571
|
+
expandedRange.setEnd(endCaret.offsetNode, endCaret.offset);
|
|
572
|
+
sub.add(expandedRange);
|
|
573
|
+
item.range = expandedRange;
|
|
574
|
+
} else {
|
|
575
|
+
sub.add(item.range);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
sub.add(item.range);
|
|
580
|
+
}
|
|
190
581
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
582
|
+
const backgroundColor = this.getBackgroundColor();
|
|
583
|
+
const applyContrast = style.enforceContrast !== false;
|
|
584
|
+
const adjustedTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
585
|
+
|
|
586
|
+
let css: string;
|
|
587
|
+
switch (type) {
|
|
588
|
+
case DecorationStyleType.Underline:
|
|
589
|
+
css = `::highlight(${subKey}) {
|
|
590
|
+
text-decoration: underline;
|
|
591
|
+
text-decoration-color: ${adjustedTint};
|
|
592
|
+
text-decoration-thickness: 0.1em;
|
|
593
|
+
}`;
|
|
594
|
+
break;
|
|
595
|
+
case DecorationStyleType.Strikethrough:
|
|
596
|
+
css = `::highlight(${subKey}) {
|
|
597
|
+
text-decoration: line-through;
|
|
598
|
+
text-decoration-color: ${adjustedTint};
|
|
599
|
+
text-decoration-thickness: 0.1em;
|
|
600
|
+
}`;
|
|
601
|
+
break;
|
|
602
|
+
case DecorationStyleType.Outline:
|
|
603
|
+
css = `::highlight(${subKey}) {
|
|
604
|
+
outline: 2px solid ${adjustedTint};
|
|
605
|
+
outline-offset: 1px;
|
|
606
|
+
}`;
|
|
607
|
+
break;
|
|
608
|
+
case DecorationStyleType.TextColor:
|
|
609
|
+
css = `::highlight(${subKey}) {
|
|
610
|
+
color: ${adjustedTint};
|
|
611
|
+
}`;
|
|
612
|
+
break;
|
|
613
|
+
case DecorationStyleType.HighlightUnderline: {
|
|
614
|
+
const { r, g, b } = colorToRgba(adjustedTint);
|
|
615
|
+
const fillTint = `rgba(${r}, ${g}, ${b}, 0.3)`;
|
|
616
|
+
css = `::highlight(${subKey}) {
|
|
617
|
+
color: ${getContrastingTextColor(adjustedTint, backgroundColor)};
|
|
618
|
+
background-color: ${fillTint};
|
|
619
|
+
text-decoration: underline;
|
|
620
|
+
text-decoration-color: ${adjustedTint};
|
|
621
|
+
text-decoration-thickness: 0.1em;
|
|
622
|
+
}`;
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
case DecorationStyleType.Highlight:
|
|
626
|
+
default:
|
|
627
|
+
css = `::highlight(${subKey}) {
|
|
628
|
+
color: ${getContrastingTextColor(adjustedTint, backgroundColor)};
|
|
629
|
+
background-color: ${adjustedTint};
|
|
630
|
+
}`;
|
|
631
|
+
}
|
|
632
|
+
item.highlightCSS = css;
|
|
633
|
+
this._rebuildHighlightStylesheet(stylesheet);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private _getSubKey(type: DecorationStyleType | string, tint: string): string {
|
|
637
|
+
const fingerprint = `${type}::${tint}`;
|
|
638
|
+
let subKey = this._tintSubKeys.get(fingerprint);
|
|
639
|
+
if (!subKey) {
|
|
640
|
+
subKey = `${this.id}--${this._subKeyCounter++}`;
|
|
641
|
+
this._tintSubKeys.set(fingerprint, subKey);
|
|
642
|
+
}
|
|
643
|
+
return subKey;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private _rebuildHighlightStylesheet(stylesheet: HTMLStyleElement) {
|
|
647
|
+
const seen = new Set<string>();
|
|
648
|
+
const rules: string[] = [];
|
|
649
|
+
for (const item of this.items) {
|
|
650
|
+
if (item.highlightSubKey && item.highlightCSS && !seen.has(item.highlightSubKey)) {
|
|
651
|
+
seen.add(item.highlightSubKey);
|
|
652
|
+
rules.push(item.highlightCSS);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
stylesheet.innerHTML = rules.join("\n");
|
|
197
656
|
}
|
|
198
657
|
|
|
199
658
|
/**
|
|
@@ -213,97 +672,223 @@ class DecorationGroup {
|
|
|
213
672
|
// itemContainer.dataset.style = item.decoration.style; // TODO style
|
|
214
673
|
itemContainer.style.setProperty("pointer-events", "none");
|
|
215
674
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
} else if (item.decoration?.style?.width === Width.Page) {
|
|
243
|
-
element.style.width = `${pageWidth}px`;
|
|
244
|
-
element.style.height = `${rect.height}px`;
|
|
245
|
-
let left = Math.floor(rect.left / pageWidth) * pageWidth;
|
|
246
|
-
element.style.left = `${left + xOffset}px`;
|
|
247
|
-
element.style.top = `${rect.top + yOffset}px`;
|
|
248
|
-
} else {
|
|
249
|
-
// Fall back to "wrap"
|
|
250
|
-
element.style.width = `${rect.width}px`;
|
|
251
|
-
element.style.height = `${rect.height}px`;
|
|
252
|
-
element.style.left = `${rect.left + xOffset}px`;
|
|
253
|
-
element.style.top = `${rect.top + yOffset}px`;
|
|
675
|
+
const ctx = makeWritingContext(this.wnd);
|
|
676
|
+
|
|
677
|
+
const iz = 1 / this.effectiveZoom();
|
|
678
|
+
|
|
679
|
+
const expand = (item.decoration.style as BuiltinDecorationStyle).expand ?? 0;
|
|
680
|
+
const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect, inlineInset = 0) => {
|
|
681
|
+
const w = item.decoration?.style?.width;
|
|
682
|
+
const r = rect;
|
|
683
|
+
switch (w) {
|
|
684
|
+
case DecorationWidth.Viewport: {
|
|
685
|
+
const snap = Math.floor(ctx.inlineStart(r) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
|
|
686
|
+
ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.viewportInlineSize - 2 * inlineInset, ctx.blockSize(r), iz);
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case DecorationWidth.Page: {
|
|
690
|
+
const snap = Math.floor(ctx.inlineStart(r) / ctx.pageInlineSize) * ctx.pageInlineSize;
|
|
691
|
+
ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.pageInlineSize - 2 * inlineInset, ctx.blockSize(r), iz);
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
case DecorationWidth.Bounds: {
|
|
695
|
+
ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(r), iz);
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
default: {
|
|
699
|
+
ctx.applyPosition(element, ctx.inlineStart(r) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(r), ctx.blockSize(r), iz);
|
|
700
|
+
}
|
|
254
701
|
}
|
|
255
702
|
}
|
|
256
|
-
|
|
257
703
|
const boundingRect = item.range.getBoundingClientRect();
|
|
258
704
|
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
705
|
+
const decoStyle = item.decoration.style;
|
|
706
|
+
// outline: 2px + outline-offset: 1px = 3px bleed outside the box on each side.
|
|
707
|
+
// For Page/Viewport widths the snap edge coincides with the viewport edge, so the
|
|
708
|
+
// outline would be clipped. Inset the element to give that bleed room to render.
|
|
709
|
+
const outlineInset = (() => {
|
|
710
|
+
if ((decoStyle as BuiltinDecorationStyle).type !== DecorationStyleType.Outline) return 0;
|
|
711
|
+
const w = (decoStyle as BuiltinDecorationStyle).width;
|
|
712
|
+
return (w === DecorationWidth.Page || w === DecorationWidth.Viewport) ? 3 : 0;
|
|
713
|
+
})();
|
|
714
|
+
let elementTemplate: Element;
|
|
715
|
+
|
|
716
|
+
if (decoStyle.type === DecorationStyleType.Template) {
|
|
717
|
+
// HTMLDecorationTemplate — fully custom HTML provided by the caller.
|
|
718
|
+
if (decoStyle.stylesheet) {
|
|
719
|
+
this.injectCustomStylesheet(decoStyle.stylesheet);
|
|
720
|
+
}
|
|
721
|
+
const customEl = sanitizeHTML(this.wnd, decoStyle.element) as HTMLElement | null;
|
|
722
|
+
if (!customEl) {
|
|
723
|
+
item.container = itemContainer;
|
|
724
|
+
item.clickableElements = [];
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
customEl.style.setProperty("pointer-events", "none");
|
|
728
|
+
elementTemplate = customEl;
|
|
729
|
+
} else {
|
|
730
|
+
// BuiltinDecorationStyle path.
|
|
731
|
+
const style = decoStyle as BuiltinDecorationStyle;
|
|
732
|
+
const type = style.type ?? DecorationStyleType.Highlight;
|
|
733
|
+
const tint = style.tint ?? defaultTint(type);
|
|
734
|
+
|
|
735
|
+
// TextColor requires CSS Highlight API; DOM overlay has no equivalent.
|
|
736
|
+
if (type === DecorationStyleType.TextColor) {
|
|
737
|
+
item.container = itemContainer;
|
|
738
|
+
item.clickableElements = [];
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Mask: dim overlay covering the full document with SVG clip-path holes.
|
|
743
|
+
if (type === DecorationStyleType.Mask) {
|
|
744
|
+
// Mask decorations use a shared overlay - just mark the item and update the shared mask
|
|
745
|
+
item.container = itemContainer;
|
|
746
|
+
item.clickableElements = [];
|
|
747
|
+
this.updateSharedMask();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const isDarkMode = this.getCurrentDarkMode();
|
|
752
|
+
const backgroundColor = this.getBackgroundColor();
|
|
753
|
+
const applyContrast = style.enforceContrast !== false;
|
|
754
|
+
const styleAttr = (() => {
|
|
755
|
+
switch (type) {
|
|
756
|
+
case DecorationStyleType.Underline: {
|
|
757
|
+
const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
758
|
+
const isBounds = style.layout === DecorationLayout.Bounds;
|
|
759
|
+
const [underlineSide, overlineSide] = ctx.isVertical
|
|
760
|
+
? ["border-right", "border-left"]
|
|
761
|
+
: ["border-bottom", "border-top"];
|
|
762
|
+
return [
|
|
763
|
+
isBounds
|
|
764
|
+
? `${overlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`
|
|
765
|
+
: null,
|
|
766
|
+
`${underlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`,
|
|
767
|
+
"background-color: transparent !important",
|
|
768
|
+
"box-sizing: border-box !important",
|
|
769
|
+
].filter(Boolean).join("; ");
|
|
770
|
+
}
|
|
771
|
+
case DecorationStyleType.Strikethrough: {
|
|
772
|
+
const adjustedStrikeTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
773
|
+
const isBounds = style.layout === DecorationLayout.Bounds;
|
|
774
|
+
if (isBounds) {
|
|
775
|
+
// Bounds covers the full height of the selection — use diagonal hatch lines
|
|
776
|
+
// so the text remains readable (physical "crossing out" appearance).
|
|
777
|
+
return [
|
|
778
|
+
`background: repeating-linear-gradient(-45deg, transparent, transparent 19px, ${adjustedStrikeTint} 19px, ${adjustedStrikeTint} 20px) !important`,
|
|
779
|
+
"background-color: transparent !important",
|
|
780
|
+
"box-sizing: border-box !important",
|
|
781
|
+
].join("; ");
|
|
782
|
+
}
|
|
783
|
+
// Boxes path: the rect is thinned and centred by the boxes loop below; just fill it.
|
|
784
|
+
return [
|
|
785
|
+
`background-color: ${adjustedStrikeTint} !important`,
|
|
786
|
+
"box-sizing: border-box !important",
|
|
787
|
+
].join("; ");
|
|
788
|
+
}
|
|
789
|
+
case DecorationStyleType.Outline:
|
|
790
|
+
const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
791
|
+
return [
|
|
792
|
+
`outline: 2px solid ${adjustedOutlineTint} !important`,
|
|
793
|
+
"outline-offset: 1px !important",
|
|
794
|
+
"background-color: transparent !important",
|
|
795
|
+
"box-sizing: border-box !important",
|
|
796
|
+
].join("; ");
|
|
797
|
+
case DecorationStyleType.HighlightUnderline: {
|
|
798
|
+
const adjustedHUTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
799
|
+
const { r, g, b } = colorToRgba(adjustedHUTint);
|
|
800
|
+
const huFillTint = `rgba(${r}, ${g}, ${b}, 0.3)`;
|
|
801
|
+
const isBounds = style.layout === DecorationLayout.Bounds;
|
|
802
|
+
const [huUnderlineSide, huOverlineSide] = ctx.isVertical
|
|
803
|
+
? ["border-right", "border-left"]
|
|
804
|
+
: ["border-bottom", "border-top"];
|
|
805
|
+
return [
|
|
806
|
+
`background-color: ${huFillTint} !important`,
|
|
807
|
+
isBounds ? `${huOverlineSide}: 0.1em solid ${adjustedHUTint} !important` : null,
|
|
808
|
+
`${huUnderlineSide}: 0.1em solid ${adjustedHUTint} !important`,
|
|
809
|
+
"box-sizing: border-box !important",
|
|
810
|
+
].filter(Boolean).join("; ");
|
|
811
|
+
}
|
|
812
|
+
case DecorationStyleType.Highlight:
|
|
813
|
+
default: {
|
|
814
|
+
const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
815
|
+
return [
|
|
816
|
+
`background-color: ${adjustedHighlightTint} !important`,
|
|
817
|
+
`mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
|
|
818
|
+
"opacity: 1 !important",
|
|
819
|
+
"box-sizing: border-box !important",
|
|
820
|
+
].join("; ");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
})();
|
|
824
|
+
|
|
825
|
+
const template = this.wnd.document.createElement("template");
|
|
826
|
+
template.innerHTML = `<div data-readium="true" class="readium-${type}" style="${styleAttr}"></div>`.trim();
|
|
827
|
+
elementTemplate = template.content.firstElementChild!;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if(item.decoration?.style?.layout === DecorationLayout.Bounds) {
|
|
282
831
|
const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
|
|
283
832
|
bounds.style.setProperty("pointer-events", "none");
|
|
284
|
-
|
|
833
|
+
const boundsRect: Rect = expand ? {
|
|
834
|
+
left: boundingRect.left - expand,
|
|
835
|
+
right: boundingRect.right + expand,
|
|
836
|
+
top: boundingRect.top - expand,
|
|
837
|
+
bottom: boundingRect.bottom + expand,
|
|
838
|
+
width: boundingRect.width + expand * 2,
|
|
839
|
+
height: boundingRect.height + expand * 2,
|
|
840
|
+
} : boundingRect;
|
|
841
|
+
positionElement(bounds, boundsRect, boundingRect, outlineInset);
|
|
285
842
|
itemContainer.append(bounds);
|
|
286
843
|
} else {
|
|
287
|
-
// Fall back to "boxes" value for layout
|
|
844
|
+
// Fall back to "boxes" value for layout.
|
|
845
|
+
// For underline/strikethrough, pre-filter to text-node rects only so Ruby
|
|
846
|
+
// (rt/rp) glyphs don't produce a separate decoration segment above the base text.
|
|
847
|
+
const decoType = (decoStyle as BuiltinDecorationStyle).type;
|
|
848
|
+
const isLineDecoration = decoType === DecorationStyleType.Underline
|
|
849
|
+
|| decoType === DecorationStyleType.Strikethrough;
|
|
850
|
+
const isStrikethrough = decoType === DecorationStyleType.Strikethrough;
|
|
851
|
+
const rectSource = isLineDecoration
|
|
852
|
+
? getTextClientRects(item.range, ["rt", "rp"])
|
|
853
|
+
: item.range;
|
|
854
|
+
// Line decorations (underline/strikethrough) don't expand in the block axis —
|
|
855
|
+
// expand extends endpoints along the inline axis only.
|
|
288
856
|
let clientRects = getClientRectsNoOverlap(
|
|
289
|
-
|
|
290
|
-
true
|
|
857
|
+
rectSource,
|
|
858
|
+
true, // doNotMergeHorizontallyAlignedRects
|
|
859
|
+
ctx.isVertical, // doNotMergeVerticallyAlignedRects
|
|
860
|
+
isLineDecoration ? 0 : expand
|
|
291
861
|
);
|
|
292
862
|
|
|
293
863
|
clientRects = clientRects.sort((r1, r2) => {
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
298
|
-
} else {
|
|
299
|
-
return 0;
|
|
864
|
+
if (ctx.isVertical) {
|
|
865
|
+
// vertical-rl: rightmost column first; vertical-lr: leftmost first
|
|
866
|
+
const factor = ctx.isVertLR ? 1 : -1;
|
|
867
|
+
return factor * (r1.left - r2.left);
|
|
300
868
|
}
|
|
869
|
+
return r1.top - r2.top;
|
|
301
870
|
});
|
|
302
871
|
|
|
303
872
|
for (let clientRect of clientRects) {
|
|
304
873
|
const line = elementTemplate.cloneNode(true) as HTMLDivElement;
|
|
305
874
|
line.style.setProperty("pointer-events", "none");
|
|
306
|
-
|
|
875
|
+
let posRect: Rect = clientRect;
|
|
876
|
+
if (isStrikethrough) {
|
|
877
|
+
// Thin the rect to ~10% of block size, centred on the mid-line.
|
|
878
|
+
const thickness = ctx.blockSize(clientRect) * 0.1;
|
|
879
|
+
const blockMid = ctx.blockStart(clientRect) + ctx.blockSize(clientRect) / 2;
|
|
880
|
+
const bs = blockMid - thickness / 2;
|
|
881
|
+
posRect = ctx.isVertical
|
|
882
|
+
? { left: bs, right: bs + thickness, top: clientRect.top, bottom: clientRect.bottom, width: thickness, height: clientRect.height }
|
|
883
|
+
: { top: bs, bottom: bs + thickness, left: clientRect.left, right: clientRect.right, height: thickness, width: clientRect.width };
|
|
884
|
+
}
|
|
885
|
+
// Expand line decorations along the inline axis only (extend endpoints, not block size).
|
|
886
|
+
if (expand && isLineDecoration) {
|
|
887
|
+
posRect = ctx.isVertical
|
|
888
|
+
? { ...posRect, top: posRect.top - expand, bottom: posRect.bottom + expand, height: posRect.height + expand * 2 }
|
|
889
|
+
: { ...posRect, left: posRect.left - expand, right: posRect.right + expand, width: posRect.width + expand * 2 };
|
|
890
|
+
}
|
|
891
|
+
positionElement(line, posRect, boundingRect, outlineInset);
|
|
307
892
|
itemContainer.append(line);
|
|
308
893
|
}
|
|
309
894
|
}
|
|
@@ -332,7 +917,7 @@ class DecorationGroup {
|
|
|
332
917
|
* Returns the group container element, after making sure it exists.
|
|
333
918
|
* @returns Group's container
|
|
334
919
|
*/
|
|
335
|
-
private requireContainer(experimental=false):
|
|
920
|
+
private requireContainer(experimental=false): HTMLStyleElement | HTMLDivElement {
|
|
336
921
|
if (experimental) {
|
|
337
922
|
// Setup <style> for highlights
|
|
338
923
|
let d: HTMLStyleElement;
|
|
@@ -345,33 +930,191 @@ class DecorationGroup {
|
|
|
345
930
|
this.wnd.document.head.appendChild(d);
|
|
346
931
|
}
|
|
347
932
|
|
|
348
|
-
|
|
349
|
-
let h: unknown;
|
|
350
|
-
if (((this.wnd as any).CSS.highlights as Map<string, unknown>).has(this.id)) {
|
|
351
|
-
h = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id)
|
|
352
|
-
} else {
|
|
353
|
-
h = new (this.wnd as any).Highlight();
|
|
354
|
-
((this.wnd as any).CSS.highlights as Map<string, unknown>).set(this.id, h);
|
|
355
|
-
}
|
|
356
|
-
return [d, h];
|
|
933
|
+
return d;
|
|
357
934
|
}
|
|
358
935
|
|
|
359
936
|
if (!this.container) {
|
|
937
|
+
// Create shared shadow host if it doesn't exist
|
|
938
|
+
if (!this.shadowRoot) {
|
|
939
|
+
this.shadowHost = this.wnd.document.createElement("div");
|
|
940
|
+
this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
|
|
941
|
+
this.wnd.document.body.appendChild(this.shadowHost);
|
|
942
|
+
this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Create container in shared shadow root
|
|
360
946
|
this.container = this.wnd.document.createElement("div");
|
|
361
947
|
this.container.setAttribute("id", this.id);
|
|
362
948
|
this.container.dataset.group = this.name;
|
|
363
949
|
this.container.dataset.readium = "true";
|
|
364
950
|
this.container.style.setProperty("pointer-events", "none");
|
|
365
951
|
this.container.style.display = "contents";
|
|
366
|
-
this.
|
|
952
|
+
this.shadowRoot.appendChild(this.container);
|
|
367
953
|
}
|
|
368
954
|
return this.container;
|
|
369
955
|
}
|
|
370
956
|
|
|
371
957
|
getCurrentDarkMode(): boolean {
|
|
372
958
|
return getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
|
|
373
|
-
isDarkColor(
|
|
374
|
-
|
|
959
|
+
isDarkColor(this.getBackgroundColor());
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
getBackgroundColor(): string {
|
|
963
|
+
return getProperty(this.wnd, "--USER__backgroundColor") ||
|
|
964
|
+
this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color");
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private updateSharedMask() {
|
|
968
|
+
const maskItems = this.items.filter(item =>
|
|
969
|
+
item.decoration.style?.type === DecorationStyleType.Mask
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
if (maskItems.length === 0) {
|
|
973
|
+
// Remove shared mask if no mask decorations exist
|
|
974
|
+
if (this.maskSvg) {
|
|
975
|
+
this.maskSvg.remove();
|
|
976
|
+
this.maskSvg = undefined;
|
|
977
|
+
}
|
|
978
|
+
if (this.shadowRoot) {
|
|
979
|
+
this.shadowRoot.innerHTML = '';
|
|
980
|
+
}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const ctx = makeWritingContext(this.wnd);
|
|
985
|
+
|
|
986
|
+
const iz = 1 / this.effectiveZoom();
|
|
987
|
+
|
|
988
|
+
// Collect all hole rects from mask decorations
|
|
989
|
+
const docEl = this.wnd.document.documentElement;
|
|
990
|
+
const docW = docEl.scrollWidth;
|
|
991
|
+
const docH = docEl.scrollHeight;
|
|
992
|
+
const allHoleRects: DOMRect[] = [];
|
|
993
|
+
for (const item of maskItems) {
|
|
994
|
+
const style = item.decoration.style as BuiltinDecorationStyle;
|
|
995
|
+
const layout = style.layout ?? DecorationLayout.Boxes;
|
|
996
|
+
const width = style.width ?? DecorationWidth.Wrap;
|
|
997
|
+
const ex = style.expand ?? 0;
|
|
998
|
+
|
|
999
|
+
const boundingRect = item.range.getBoundingClientRect();
|
|
1000
|
+
|
|
1001
|
+
// For Bounds layout the hole is a single bounding rect; expand it directly.
|
|
1002
|
+
// For Boxes layout, merge rects first with expand baked in (same as highlights).
|
|
1003
|
+
const baseRects: Rect[] = layout === DecorationLayout.Bounds
|
|
1004
|
+
? [ex ? { left: boundingRect.left - ex, top: boundingRect.top - ex, right: boundingRect.right + ex, bottom: boundingRect.bottom + ex, width: boundingRect.width + ex * 2, height: boundingRect.height + ex * 2 } : boundingRect]
|
|
1005
|
+
: getClientRectsNoOverlap(item.range, false, false, ex);
|
|
1006
|
+
|
|
1007
|
+
for (const rect of baseRects) {
|
|
1008
|
+
let hole: DOMRect;
|
|
1009
|
+
switch (width) {
|
|
1010
|
+
case DecorationWidth.Viewport: {
|
|
1011
|
+
const snap = Math.floor(ctx.inlineStart(rect) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
|
|
1012
|
+
hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.viewportInlineSize, ctx.blockSize(rect));
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case DecorationWidth.Page: {
|
|
1016
|
+
const snap = Math.floor(ctx.inlineStart(rect) / ctx.pageInlineSize) * ctx.pageInlineSize;
|
|
1017
|
+
hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.pageInlineSize, ctx.blockSize(rect));
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
case DecorationWidth.Bounds: {
|
|
1021
|
+
hole = ctx.toRect(ctx.inlineStart(boundingRect), ctx.blockStart(rect), ctx.inlineSize(boundingRect), ctx.blockSize(rect));
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
default:
|
|
1025
|
+
hole = ctx.toRect(ctx.inlineStart(rect), ctx.blockStart(rect), ctx.inlineSize(rect), ctx.blockSize(rect));
|
|
1026
|
+
}
|
|
1027
|
+
allHoleRects.push(hole);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Build SVG path with all holes (physical coords — always left/top regardless of writing mode)
|
|
1032
|
+
const pathData = [
|
|
1033
|
+
`M0 0 H${docW} V${docH} H0 Z`,
|
|
1034
|
+
...allHoleRects.map(r => {
|
|
1035
|
+
const l = (r.left + ctx.xDocOffset) * iz;
|
|
1036
|
+
const t = (r.top + ctx.yDocOffset) * iz;
|
|
1037
|
+
const ri = (r.right + ctx.xDocOffset) * iz;
|
|
1038
|
+
const b = (r.bottom + ctx.yDocOffset) * iz;
|
|
1039
|
+
return `M${l} ${t} H${ri} V${b} H${l} Z`;
|
|
1040
|
+
}),
|
|
1041
|
+
].join(" ");
|
|
1042
|
+
|
|
1043
|
+
const svgNS = "http://www.w3.org/2000/svg";
|
|
1044
|
+
|
|
1045
|
+
// Create or update SVG
|
|
1046
|
+
if (!this.maskSvg) {
|
|
1047
|
+
// Ensure shared shadow host exists
|
|
1048
|
+
if (!this.shadowRoot) {
|
|
1049
|
+
this.shadowHost = this.wnd.document.createElement("div");
|
|
1050
|
+
this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
|
|
1051
|
+
this.wnd.document.body.appendChild(this.shadowHost);
|
|
1052
|
+
this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Create SVG in shared shadow root
|
|
1056
|
+
this.maskSvg = this.wnd.document.createElementNS(svgNS, "svg") as SVGSVGElement;
|
|
1057
|
+
this.maskSvg.style.cssText = `position:absolute;top:0;left:0;width:${docW}px;height:${docH}px;pointer-events:none;z-index:9999`;
|
|
1058
|
+
this.maskSvg.dataset.readium = "true";
|
|
1059
|
+
const defs = this.wnd.document.createElementNS(svgNS, "defs");
|
|
1060
|
+
const clipPath = this.wnd.document.createElementNS(svgNS, "clipPath") as SVGClipPathElement;
|
|
1061
|
+
const clipId = `${this.id}-mask-clip`;
|
|
1062
|
+
clipPath.setAttribute("id", clipId);
|
|
1063
|
+
clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
|
|
1064
|
+
const svgPath = this.wnd.document.createElementNS(svgNS, "path") as SVGPathElement;
|
|
1065
|
+
svgPath.setAttribute("clip-rule", "evenodd");
|
|
1066
|
+
clipPath.appendChild(svgPath);
|
|
1067
|
+
defs.appendChild(clipPath);
|
|
1068
|
+
this.maskSvg.appendChild(defs);
|
|
1069
|
+
|
|
1070
|
+
// Add SVG rect for the overlay (bypasses ReadiumCSS)
|
|
1071
|
+
const maskRect = this.wnd.document.createElementNS(svgNS, "rect") as SVGRectElement;
|
|
1072
|
+
maskRect.setAttribute("id", `${this.id}-mask-rect`);
|
|
1073
|
+
maskRect.setAttribute("clip-path", `url(#${clipId})`);
|
|
1074
|
+
maskRect.style.pointerEvents = "none";
|
|
1075
|
+
this.maskSvg.appendChild(maskRect);
|
|
1076
|
+
|
|
1077
|
+
this.shadowRoot!.appendChild(this.maskSvg);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Update SVG dimensions to cover full document
|
|
1081
|
+
this.maskSvg.style.width = `${docW}px`;
|
|
1082
|
+
this.maskSvg.style.height = `${docH}px`;
|
|
1083
|
+
|
|
1084
|
+
// Update the path data
|
|
1085
|
+
const svgPath = this.maskSvg.querySelector("path") as SVGPathElement;
|
|
1086
|
+
if (svgPath) {
|
|
1087
|
+
svgPath.setAttribute("d", pathData);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Update the mask rect
|
|
1091
|
+
const maskRect = this.maskSvg.querySelector("rect") as SVGRectElement;
|
|
1092
|
+
if (maskRect) {
|
|
1093
|
+
const firstMaskStyle = maskItems[0].decoration.style as BuiltinDecorationStyle;
|
|
1094
|
+
const userTint = firstMaskStyle.tint;
|
|
1095
|
+
// User-supplied tint: alpha is their responsibility (SVG fill respects rgba natively).
|
|
1096
|
+
// Background color fallback: always fully opaque, so apply default dimming.
|
|
1097
|
+
const fillColor = userTint ?? this.getBackgroundColor() ?? defaultTint(DecorationStyleType.Mask);
|
|
1098
|
+
const fillOpacity = userTint ? "1" : "0.5";
|
|
1099
|
+
maskRect.setAttribute("x", "0");
|
|
1100
|
+
maskRect.setAttribute("y", "0");
|
|
1101
|
+
maskRect.setAttribute("width", String(docW));
|
|
1102
|
+
maskRect.setAttribute("height", String(docH));
|
|
1103
|
+
maskRect.setAttribute("fill", fillColor);
|
|
1104
|
+
maskRect.setAttribute("fill-opacity", fillOpacity);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private injectCustomStylesheet(css: string) {
|
|
1109
|
+
const id = `${this.id}-custom-style`;
|
|
1110
|
+
let el = this.wnd.document.getElementById(id) as HTMLStyleElement | null;
|
|
1111
|
+
if (!el) {
|
|
1112
|
+
el = this.wnd.document.createElement("style");
|
|
1113
|
+
el.id = id;
|
|
1114
|
+
el.dataset.readium = "true";
|
|
1115
|
+
this.wnd.document.head.appendChild(el);
|
|
1116
|
+
}
|
|
1117
|
+
el.innerHTML = css;
|
|
375
1118
|
}
|
|
376
1119
|
|
|
377
1120
|
/**
|
|
@@ -379,8 +1122,14 @@ class DecorationGroup {
|
|
|
379
1122
|
*/
|
|
380
1123
|
private clearContainer() {
|
|
381
1124
|
if (this.experimentalHighlights) {
|
|
382
|
-
(
|
|
1125
|
+
const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, unknown>;
|
|
1126
|
+
for (const subKey of this._tintSubKeys.values()) {
|
|
1127
|
+
cssHighlights.delete(subKey);
|
|
1128
|
+
}
|
|
1129
|
+
this._tintSubKeys.clear();
|
|
1130
|
+
this._subKeyCounter = 0;
|
|
383
1131
|
}
|
|
1132
|
+
this.wnd.document.getElementById(`${this.id}-custom-style`)?.remove();
|
|
384
1133
|
if (this.container) {
|
|
385
1134
|
this.container.remove();
|
|
386
1135
|
this.container = undefined;
|
|
@@ -391,8 +1140,8 @@ class DecorationGroup {
|
|
|
391
1140
|
export class Decorator extends Module {
|
|
392
1141
|
static readonly moduleName: ModuleName = "decorator";
|
|
393
1142
|
private resizeObserver!: ResizeObserver;
|
|
394
|
-
private
|
|
395
|
-
private wnd!:
|
|
1143
|
+
private styleObserver!: MutationObserver;
|
|
1144
|
+
private wnd!: Window;
|
|
396
1145
|
/*private readonly lastSize = {
|
|
397
1146
|
width: 0,
|
|
398
1147
|
height: 0
|
|
@@ -403,8 +1152,7 @@ export class Decorator extends Module {
|
|
|
403
1152
|
private groups = new Map<string, DecorationGroup>();
|
|
404
1153
|
|
|
405
1154
|
private cleanup() {
|
|
406
|
-
|
|
407
|
-
this.groups.forEach(g => g.clear());
|
|
1155
|
+
this.groups.forEach(g => g.destroy());
|
|
408
1156
|
this.groups.clear();
|
|
409
1157
|
}
|
|
410
1158
|
|
|
@@ -414,13 +1162,6 @@ export class Decorator extends Module {
|
|
|
414
1162
|
});
|
|
415
1163
|
}
|
|
416
1164
|
|
|
417
|
-
private extractCustomProperty(style: string | null, propertyName: string): string | null {
|
|
418
|
-
if (!style) return null;
|
|
419
|
-
|
|
420
|
-
const match = style.match(new RegExp(`${propertyName}:\\s*([^;]+)`));
|
|
421
|
-
return match ? match[1].trim() : null;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
1165
|
private handleResize() {
|
|
425
1166
|
this.wnd.clearTimeout(this.resizeFrame);
|
|
426
1167
|
this.resizeFrame = this.wnd.setTimeout(() => {
|
|
@@ -431,13 +1172,15 @@ export class Decorator extends Module {
|
|
|
431
1172
|
}
|
|
432
1173
|
private readonly handleResizer = this.handleResize.bind(this);
|
|
433
1174
|
|
|
434
|
-
mount(wnd:
|
|
1175
|
+
mount(wnd: Window, comms: IComms): boolean {
|
|
435
1176
|
this.wnd = wnd;
|
|
436
1177
|
|
|
437
1178
|
comms.register("decorate", Decorator.moduleName, (data, ack) => {
|
|
438
1179
|
const req = data as DecoratorRequest;
|
|
439
|
-
if (req.
|
|
440
|
-
|
|
1180
|
+
if (req.action === "add" || req.action === "update") {
|
|
1181
|
+
if (req.decoration.locator) {
|
|
1182
|
+
req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
|
|
1183
|
+
}
|
|
441
1184
|
}
|
|
442
1185
|
if (!this.groups.has(req.group)) {
|
|
443
1186
|
this.groups.set(req.group, new DecorationGroup(
|
|
@@ -450,70 +1193,73 @@ export class Decorator extends Module {
|
|
|
450
1193
|
const group = this.groups.get(req.group);
|
|
451
1194
|
switch (req.action) {
|
|
452
1195
|
case "add":
|
|
453
|
-
group?.add(req.decoration
|
|
1196
|
+
group?.add(req.decoration);
|
|
454
1197
|
break;
|
|
455
1198
|
case "remove":
|
|
456
|
-
group?.remove(req.decoration
|
|
1199
|
+
group?.remove(req.decoration.id);
|
|
457
1200
|
break;
|
|
458
1201
|
case "clear":
|
|
459
1202
|
group?.clear();
|
|
460
1203
|
break;
|
|
461
1204
|
case "update":
|
|
462
|
-
group?.update(req.decoration
|
|
1205
|
+
group?.update(req.decoration);
|
|
463
1206
|
break;
|
|
464
1207
|
}
|
|
465
1208
|
|
|
466
1209
|
ack(true);
|
|
467
1210
|
});
|
|
468
1211
|
|
|
1212
|
+
comms.register("decoration_activatable", Decorator.moduleName, (data, ack) => {
|
|
1213
|
+
const req = data as { group: string; activatable: boolean };
|
|
1214
|
+
const group = this.groups.get(req.group);
|
|
1215
|
+
if (group) {
|
|
1216
|
+
group.activatable = req.activatable;
|
|
1217
|
+
}
|
|
1218
|
+
ack(true);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
comms.register("decoration_hoverable", Decorator.moduleName, (data, ack) => {
|
|
1222
|
+
const req = data as { group: string; hoverable: boolean };
|
|
1223
|
+
const group = this.groups.get(req.group);
|
|
1224
|
+
if (group) {
|
|
1225
|
+
group.hoverable = req.hoverable;
|
|
1226
|
+
}
|
|
1227
|
+
ack(true);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
469
1230
|
this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
|
|
470
|
-
this.resizeObserver.observe(wnd.document.
|
|
1231
|
+
this.resizeObserver.observe(wnd.document.documentElement);
|
|
471
1232
|
wnd.addEventListener("orientationchange", this.handleResizer);
|
|
472
1233
|
wnd.addEventListener("resize", this.handleResizer);
|
|
473
1234
|
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const oldAppearance = this.extractCustomProperty(oldStyle, "--USER__appearance");
|
|
484
|
-
const newAppearance = this.extractCustomProperty(newStyle, "--USER__appearance");
|
|
485
|
-
const oldBgColor = this.extractCustomProperty(oldStyle, "--USER__backgroundColor");
|
|
486
|
-
const newBgColor = this.extractCustomProperty(newStyle, "--USER__backgroundColor");
|
|
487
|
-
|
|
488
|
-
return oldAppearance !== newAppearance ||
|
|
489
|
-
oldBgColor !== newBgColor;
|
|
490
|
-
}
|
|
491
|
-
return false;
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
if (shouldUpdate) {
|
|
495
|
-
this.updateHighlightStyles();
|
|
496
|
-
}
|
|
1235
|
+
// Watch for any style change on <html> — covers appearance, background color,
|
|
1236
|
+
// font size, line height, margins, and anything else that reflows text.
|
|
1237
|
+
this.styleObserver = new MutationObserver((mutations) => {
|
|
1238
|
+
const shouldUpdate = mutations.some(mutation =>
|
|
1239
|
+
mutation.type === "attributes" &&
|
|
1240
|
+
mutation.attributeName === "style" &&
|
|
1241
|
+
mutation.oldValue !== (mutation.target as Element).getAttribute("style")
|
|
1242
|
+
);
|
|
1243
|
+
if (shouldUpdate) this.updateHighlightStyles();
|
|
497
1244
|
});
|
|
498
1245
|
|
|
499
|
-
this.
|
|
1246
|
+
this.styleObserver.observe(wnd.document.documentElement, {
|
|
500
1247
|
attributes: true,
|
|
501
1248
|
attributeFilter: ["style"],
|
|
502
1249
|
attributeOldValue: true,
|
|
503
|
-
subtree: true
|
|
504
1250
|
});
|
|
505
1251
|
|
|
506
1252
|
comms.log("Decorator Mounted");
|
|
507
1253
|
return true;
|
|
508
1254
|
}
|
|
509
1255
|
|
|
510
|
-
unmount(wnd:
|
|
1256
|
+
unmount(wnd: Window, comms: IComms): boolean {
|
|
511
1257
|
wnd.removeEventListener("orientationchange", this.handleResizer);
|
|
512
1258
|
wnd.removeEventListener("resize", this.handleResizer);
|
|
513
1259
|
|
|
514
1260
|
comms.unregisterAll(Decorator.moduleName);
|
|
515
1261
|
this.resizeObserver.disconnect();
|
|
516
|
-
this.
|
|
1262
|
+
this.styleObserver.disconnect();
|
|
517
1263
|
this.cleanup();
|
|
518
1264
|
|
|
519
1265
|
comms.log("Decorator Unmounted");
|