@readium/navigator-html-injectables 2.5.0 → 2.6.1
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 -4601
- 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 +14 -13
- package/src/comms/comms.ts +11 -1
- package/src/comms/keys.ts +6 -2
- package/src/helpers/css.ts +1 -1
- package/src/helpers/document.ts +5 -5
- package/src/helpers/locator.ts +5 -5
- package/src/helpers/rect.ts +79 -18
- package/src/index.ts +1 -1
- package/src/modules/Decorator.ts +404 -107
- package/src/modules/snapper/CJKVerticalSnapper.ts +34 -7
- package/src/modules/snapper/ColumnSnapper.ts +54 -6
- 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 +1 -1
- package/types/src/helpers/document.d.ts +5 -5
- package/types/src/helpers/rect.d.ts +2 -1
- package/types/src/index.d.ts +1 -1
- package/types/src/modules/Decorator.d.ts +33 -7
- 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 -96
- 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 -268
- package/src/helpers/sML.ts +0 -143
- package/types/src/helpers/color.d.ts +0 -27
- package/types/src/helpers/sML.d.ts +0 -56
package/src/modules/Decorator.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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, rectContainsPoint } 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 { isDarkColor, getContrastingTextColor, adjustColorForContrast } from "../helpers/color.ts";
|
|
8
|
+
import { isDarkColor, getContrastingTextColor, adjustColorForContrast, colorToRgba } from "@readium/helpers";
|
|
10
9
|
import { makeWritingContext } from "../helpers/document.ts";
|
|
11
|
-
import { sML } from "
|
|
10
|
+
import { sML } from "@readium/helpers";
|
|
12
11
|
import { sanitizeHTML } from "../helpers/sanitize.ts";
|
|
13
12
|
|
|
14
13
|
function defaultTint(type: DecorationStyleType): string {
|
|
@@ -16,6 +15,7 @@ function defaultTint(type: DecorationStyleType): string {
|
|
|
16
15
|
case DecorationStyleType.Mask:
|
|
17
16
|
return "rgba(255, 255, 255, 0.5)";
|
|
18
17
|
case DecorationStyleType.Highlight:
|
|
18
|
+
case DecorationStyleType.HighlightUnderline:
|
|
19
19
|
return "#FFFF00";
|
|
20
20
|
default:
|
|
21
21
|
return "#FF0000";
|
|
@@ -23,12 +23,14 @@ function defaultTint(type: DecorationStyleType): string {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export const DecorationStyleType = {
|
|
26
|
-
Highlight:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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).
|
|
32
34
|
} as const;
|
|
33
35
|
export type DecorationStyleType = typeof DecorationStyleType[keyof typeof DecorationStyleType];
|
|
34
36
|
|
|
@@ -50,8 +52,8 @@ export interface BuiltinDecorationStyle {
|
|
|
50
52
|
tint?: string;
|
|
51
53
|
layout?: DecorationLayout;
|
|
52
54
|
width?: DecorationWidth;
|
|
53
|
-
isActive?: boolean;
|
|
54
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.
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -66,7 +68,6 @@ export interface HTMLDecorationTemplate {
|
|
|
66
68
|
width: DecorationWidth;
|
|
67
69
|
element: string;
|
|
68
70
|
stylesheet?: string;
|
|
69
|
-
isActive?: boolean;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
|
|
@@ -78,13 +79,25 @@ export interface Decoration {
|
|
|
78
79
|
extras?: Record<string, unknown>; // App-specific context data passed through to DecorationActivationEvent.
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
interface DecorationEventBase {
|
|
82
83
|
decorationId: string;
|
|
83
|
-
group: string;
|
|
84
|
-
rect
|
|
85
|
-
point
|
|
84
|
+
group: string;
|
|
85
|
+
rect?: { top: number; left: number; width: number; height: number };
|
|
86
|
+
point?: { x: number; y: number };
|
|
86
87
|
}
|
|
87
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
|
+
|
|
88
101
|
export type DecoratorRequest =
|
|
89
102
|
| { group: string; action: "add" | "update"; decoration: Decoration }
|
|
90
103
|
| { group: string; action: "remove"; decoration: Pick<Decoration, "id"> }
|
|
@@ -94,9 +107,11 @@ interface DecorationItem {
|
|
|
94
107
|
id: string;
|
|
95
108
|
decoration: Decoration;
|
|
96
109
|
range: Range;
|
|
97
|
-
|
|
110
|
+
hitRects: Rect[]; // Merged client rects for hit testing; refreshed after each layout.
|
|
98
111
|
clickableElements: HTMLElement[] | undefined;
|
|
99
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.
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
const canNativeHighlight = () => ("Highlight" in window);
|
|
@@ -107,9 +122,14 @@ class DecorationGroup {
|
|
|
107
122
|
private lastItemId = 0;
|
|
108
123
|
private container: HTMLDivElement | undefined = undefined;
|
|
109
124
|
private _activatable = false;
|
|
125
|
+
private _hoverable = false;
|
|
126
|
+
private hoveredItem: DecorationItem | undefined = undefined;
|
|
110
127
|
public readonly experimentalHighlights: boolean = false;
|
|
111
128
|
private readonly notTextFlag: Map<string, boolean> | undefined;
|
|
129
|
+
private readonly _tintSubKeys = new Map<string, string>(); // (type::adjustedTint) → subKey
|
|
130
|
+
private _subKeyCounter = 0;
|
|
112
131
|
private readonly activationHandler: (e: PointerEvent) => void;
|
|
132
|
+
private readonly hoverHandler: (e: PointerEvent) => void;
|
|
113
133
|
private maskSvg: SVGSVGElement | undefined = undefined;
|
|
114
134
|
private shadowHost: HTMLDivElement | undefined = undefined;
|
|
115
135
|
private shadowRoot: ShadowRoot | undefined = undefined;
|
|
@@ -120,8 +140,8 @@ class DecorationGroup {
|
|
|
120
140
|
* @param name Human-readable name of the group
|
|
121
141
|
*/
|
|
122
142
|
constructor(
|
|
123
|
-
private readonly wnd:
|
|
124
|
-
private readonly comms:
|
|
143
|
+
private readonly wnd: Window,
|
|
144
|
+
private readonly comms: IComms,
|
|
125
145
|
private readonly id: string,
|
|
126
146
|
private readonly name: string
|
|
127
147
|
) {
|
|
@@ -131,6 +151,8 @@ class DecorationGroup {
|
|
|
131
151
|
}
|
|
132
152
|
this.activationHandler = this.handleActivation.bind(this);
|
|
133
153
|
this.wnd.document.addEventListener("pointerup", this.activationHandler);
|
|
154
|
+
this.hoverHandler = this.handleHover.bind(this);
|
|
155
|
+
this.wnd.document.addEventListener("pointermove", this.hoverHandler);
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
get activatable() {
|
|
@@ -141,6 +163,29 @@ class DecorationGroup {
|
|
|
141
163
|
this._activatable = value;
|
|
142
164
|
}
|
|
143
165
|
|
|
166
|
+
get hoverable() {
|
|
167
|
+
return this._hoverable;
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
144
189
|
/**
|
|
145
190
|
* Adds a new decoration to the group.
|
|
146
191
|
* @param decoration Decoration to add
|
|
@@ -170,9 +215,23 @@ class DecorationGroup {
|
|
|
170
215
|
this.notTextFlag?.set(id, true);
|
|
171
216
|
}
|
|
172
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
|
+
}
|
|
173
232
|
if (this.experimentalHighlights) {
|
|
174
233
|
const { type } = decoration.style;
|
|
175
|
-
const { layout, width } = decoration.style as BuiltinDecorationStyle;
|
|
234
|
+
const { layout, width, expand } = decoration.style as BuiltinDecorationStyle;
|
|
176
235
|
// CSS Highlight API only handles text-level highlight styling (boxes + wrap).
|
|
177
236
|
// Everything else must go through the DOM overlay path.
|
|
178
237
|
const needsDomOverlay =
|
|
@@ -181,7 +240,8 @@ class DecorationGroup {
|
|
|
181
240
|
type === DecorationStyleType.Template ||
|
|
182
241
|
type === DecorationStyleType.Mask ||
|
|
183
242
|
(layout !== undefined && layout !== DecorationLayout.Boxes) ||
|
|
184
|
-
(width !== undefined && width !== DecorationWidth.Wrap)
|
|
243
|
+
(width !== undefined && width !== DecorationWidth.Wrap) ||
|
|
244
|
+
!!expand
|
|
185
245
|
);
|
|
186
246
|
if (needsDomOverlay) this.notTextFlag?.set(id, true);
|
|
187
247
|
}
|
|
@@ -190,10 +250,14 @@ class DecorationGroup {
|
|
|
190
250
|
decoration,
|
|
191
251
|
id,
|
|
192
252
|
range,
|
|
253
|
+
hitRects: [],
|
|
254
|
+
clickableElements: undefined,
|
|
255
|
+
container: undefined,
|
|
193
256
|
} as DecorationItem;
|
|
194
257
|
|
|
195
258
|
this.items.push(item);
|
|
196
259
|
this.layout(item);
|
|
260
|
+
item.hitRects = this.clientRectsToDocCoords(getClientRectsNoOverlap(item.range, false, false, ((item.decoration.style as BuiltinDecorationStyle).expand ?? 0) + this.hitGap()));
|
|
197
261
|
this.renderLayout([item]);
|
|
198
262
|
}
|
|
199
263
|
|
|
@@ -214,13 +278,20 @@ class DecorationGroup {
|
|
|
214
278
|
item.container.remove();
|
|
215
279
|
item.container = undefined;
|
|
216
280
|
}
|
|
217
|
-
if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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);
|
|
221
289
|
}
|
|
222
290
|
this.notTextFlag?.delete(item.id);
|
|
223
|
-
|
|
291
|
+
if (this.hoveredItem === item) {
|
|
292
|
+
this.hoveredItem = undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
224
295
|
// Update shared mask if we removed a mask decoration
|
|
225
296
|
if (wasMask) {
|
|
226
297
|
this.updateSharedMask();
|
|
@@ -243,6 +314,7 @@ class DecorationGroup {
|
|
|
243
314
|
this.clearContainer();
|
|
244
315
|
this.items.length = 0;
|
|
245
316
|
this.notTextFlag?.clear();
|
|
317
|
+
this.hoveredItem = undefined;
|
|
246
318
|
// Clear shared mask
|
|
247
319
|
if (this.maskSvg) {
|
|
248
320
|
this.maskSvg.remove();
|
|
@@ -262,17 +334,43 @@ class DecorationGroup {
|
|
|
262
334
|
destroy() {
|
|
263
335
|
this.clear();
|
|
264
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();
|
|
265
366
|
}
|
|
266
367
|
|
|
267
368
|
private handleActivation(e: PointerEvent) {
|
|
268
369
|
if (!this._activatable) return;
|
|
269
|
-
const
|
|
270
|
-
const cssY = e.clientY;
|
|
370
|
+
const { docX, docY } = this.pointerToDocCoords(e);
|
|
271
371
|
const pixelRatio = this.wnd.devicePixelRatio;
|
|
272
372
|
|
|
273
373
|
for (const item of this.items) {
|
|
274
|
-
if (!item.decoration.style?.isActive) continue;
|
|
275
|
-
|
|
276
374
|
let hitRect: DOMRect | undefined;
|
|
277
375
|
|
|
278
376
|
if (item.decoration.style.type === DecorationStyleType.Template) {
|
|
@@ -280,18 +378,16 @@ class DecorationGroup {
|
|
|
280
378
|
// against the rendered elements rather than the text range rects.
|
|
281
379
|
for (const el of (item.clickableElements ?? [])) {
|
|
282
380
|
const r = el.getBoundingClientRect();
|
|
283
|
-
if (rectContainsPoint(r as Rect,
|
|
381
|
+
if (rectContainsPoint(r as Rect, e.clientX, e.clientY, 0)) {
|
|
284
382
|
hitRect = r;
|
|
285
383
|
break;
|
|
286
384
|
}
|
|
287
385
|
}
|
|
288
386
|
} else {
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
for (const rect of rects) {
|
|
294
|
-
if (rectContainsPoint(rect as Rect, cssX, cssY, 0)) {
|
|
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)) {
|
|
295
391
|
hitRect = item.range.getBoundingClientRect();
|
|
296
392
|
break;
|
|
297
393
|
}
|
|
@@ -308,13 +404,79 @@ class DecorationGroup {
|
|
|
308
404
|
width: hitRect.width * pixelRatio,
|
|
309
405
|
height: hitRect.height * pixelRatio,
|
|
310
406
|
},
|
|
311
|
-
point: { x:
|
|
407
|
+
point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
|
|
312
408
|
} as DecorationActivatedEvent);
|
|
313
409
|
return;
|
|
314
410
|
}
|
|
315
411
|
}
|
|
316
412
|
}
|
|
317
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
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
318
480
|
/**
|
|
319
481
|
* Recreates the decoration elements.
|
|
320
482
|
* To be called after reflowing the resource, for example.
|
|
@@ -328,7 +490,10 @@ class DecorationGroup {
|
|
|
328
490
|
// against stale or fallback-font layout.
|
|
329
491
|
this.wnd.document.fonts.ready.then(() => {
|
|
330
492
|
this.currentRender = this.wnd.requestAnimationFrame(() => {
|
|
331
|
-
this.items.forEach(i =>
|
|
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
|
+
});
|
|
332
497
|
this.renderLayout(this.items);
|
|
333
498
|
// Update shared mask after layout
|
|
334
499
|
this.updateSharedMask();
|
|
@@ -337,7 +502,8 @@ class DecorationGroup {
|
|
|
337
502
|
}
|
|
338
503
|
|
|
339
504
|
private experimentalLayout(item: DecorationItem) {
|
|
340
|
-
const
|
|
505
|
+
const stylesheet = this.requireContainer(true) as HTMLStyleElement;
|
|
506
|
+
const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, any>;
|
|
341
507
|
|
|
342
508
|
// Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here.
|
|
343
509
|
const style = item.decoration.style as BuiltinDecorationStyle;
|
|
@@ -346,6 +512,29 @@ class DecorationGroup {
|
|
|
346
512
|
const width = style.width;
|
|
347
513
|
const layout = style.layout;
|
|
348
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
|
+
}
|
|
537
|
+
|
|
349
538
|
// Helper for caret position
|
|
350
539
|
const caretPositionFromPoint = (x: number, y: number): CaretPosition | null => {
|
|
351
540
|
return this.wnd.document.caretPositionFromPoint?.(x, y) ?? null;
|
|
@@ -360,7 +549,7 @@ class DecorationGroup {
|
|
|
360
549
|
const ctx = makeWritingContext(this.wnd);
|
|
361
550
|
if (ctx.isVertical) {
|
|
362
551
|
console.warn('Vertical writing detected: caretPositionFromPoint has known bugs, falling back to original range');
|
|
363
|
-
|
|
552
|
+
sub.add(item.range);
|
|
364
553
|
} else {
|
|
365
554
|
const boundingRect = item.range.getBoundingClientRect();
|
|
366
555
|
// Page snaps to the full page inline extent; Bounds/layout:Bounds uses the actual bounding rect.
|
|
@@ -380,53 +569,90 @@ class DecorationGroup {
|
|
|
380
569
|
const expandedRange = this.wnd.document.createRange();
|
|
381
570
|
expandedRange.setStart(startCaret.offsetNode, startCaret.offset);
|
|
382
571
|
expandedRange.setEnd(endCaret.offsetNode, endCaret.offset);
|
|
383
|
-
|
|
572
|
+
sub.add(expandedRange);
|
|
384
573
|
item.range = expandedRange;
|
|
385
574
|
} else {
|
|
386
|
-
|
|
575
|
+
sub.add(item.range);
|
|
387
576
|
}
|
|
388
577
|
}
|
|
389
578
|
} else {
|
|
390
|
-
|
|
579
|
+
sub.add(item.range);
|
|
391
580
|
}
|
|
392
581
|
|
|
393
|
-
// TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
|
|
394
582
|
const backgroundColor = this.getBackgroundColor();
|
|
395
583
|
const applyContrast = style.enforceContrast !== false;
|
|
584
|
+
const adjustedTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
585
|
+
|
|
396
586
|
let css: string;
|
|
397
587
|
switch (type) {
|
|
398
588
|
case DecorationStyleType.Underline:
|
|
399
|
-
|
|
400
|
-
css = `::highlight(${this.id}) {
|
|
589
|
+
css = `::highlight(${subKey}) {
|
|
401
590
|
text-decoration: underline;
|
|
402
|
-
text-decoration-color: ${
|
|
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};
|
|
403
599
|
text-decoration-thickness: 0.1em;
|
|
404
600
|
}`;
|
|
405
601
|
break;
|
|
406
602
|
case DecorationStyleType.Outline:
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
outline: 2px solid ${adjustedOutlineTint};
|
|
603
|
+
css = `::highlight(${subKey}) {
|
|
604
|
+
outline: 2px solid ${adjustedTint};
|
|
410
605
|
outline-offset: 1px;
|
|
411
606
|
}`;
|
|
412
607
|
break;
|
|
413
|
-
case DecorationStyleType.TextColor:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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;
|
|
417
622
|
}`;
|
|
418
623
|
break;
|
|
419
624
|
}
|
|
420
625
|
case DecorationStyleType.Highlight:
|
|
421
|
-
default:
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
color: ${
|
|
425
|
-
background-color: ${adjustedHighlightTint};
|
|
626
|
+
default:
|
|
627
|
+
css = `::highlight(${subKey}) {
|
|
628
|
+
color: ${getContrastingTextColor(adjustedTint, backgroundColor)};
|
|
629
|
+
background-color: ${adjustedTint};
|
|
426
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);
|
|
427
653
|
}
|
|
428
654
|
}
|
|
429
|
-
stylesheet.innerHTML =
|
|
655
|
+
stylesheet.innerHTML = rules.join("\n");
|
|
430
656
|
}
|
|
431
657
|
|
|
432
658
|
/**
|
|
@@ -448,33 +674,29 @@ class DecorationGroup {
|
|
|
448
674
|
|
|
449
675
|
const ctx = makeWritingContext(this.wnd);
|
|
450
676
|
|
|
451
|
-
|
|
452
|
-
if (sML.UA.Blink) {
|
|
453
|
-
const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
|
|
454
|
-
const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
|
|
455
|
-
const effectiveZoom = (rootZoom || 1) * (bodyZoom || 1);
|
|
456
|
-
if (effectiveZoom) iz = 1 / effectiveZoom;
|
|
457
|
-
}
|
|
677
|
+
const iz = 1 / this.effectiveZoom();
|
|
458
678
|
|
|
679
|
+
const expand = (item.decoration.style as BuiltinDecorationStyle).expand ?? 0;
|
|
459
680
|
const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect, inlineInset = 0) => {
|
|
460
681
|
const w = item.decoration?.style?.width;
|
|
682
|
+
const r = rect;
|
|
461
683
|
switch (w) {
|
|
462
684
|
case DecorationWidth.Viewport: {
|
|
463
|
-
const snap = Math.floor(ctx.inlineStart(
|
|
464
|
-
ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(
|
|
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);
|
|
465
687
|
break;
|
|
466
688
|
}
|
|
467
689
|
case DecorationWidth.Page: {
|
|
468
|
-
const snap = Math.floor(ctx.inlineStart(
|
|
469
|
-
ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(
|
|
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);
|
|
470
692
|
break;
|
|
471
693
|
}
|
|
472
694
|
case DecorationWidth.Bounds: {
|
|
473
|
-
ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(
|
|
695
|
+
ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(r), iz);
|
|
474
696
|
break;
|
|
475
697
|
}
|
|
476
698
|
default: {
|
|
477
|
-
ctx.applyPosition(element, ctx.inlineStart(
|
|
699
|
+
ctx.applyPosition(element, ctx.inlineStart(r) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(r), ctx.blockSize(r), iz);
|
|
478
700
|
}
|
|
479
701
|
}
|
|
480
702
|
}
|
|
@@ -534,15 +756,36 @@ class DecorationGroup {
|
|
|
534
756
|
case DecorationStyleType.Underline: {
|
|
535
757
|
const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
536
758
|
const isBounds = style.layout === DecorationLayout.Bounds;
|
|
759
|
+
const [underlineSide, overlineSide] = ctx.isVertical
|
|
760
|
+
? ["border-right", "border-left"]
|
|
761
|
+
: ["border-bottom", "border-top"];
|
|
537
762
|
return [
|
|
538
763
|
isBounds
|
|
539
|
-
?
|
|
764
|
+
? `${overlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`
|
|
540
765
|
: null,
|
|
541
|
-
|
|
766
|
+
`${underlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`,
|
|
542
767
|
"background-color: transparent !important",
|
|
543
768
|
"box-sizing: border-box !important",
|
|
544
769
|
].filter(Boolean).join("; ");
|
|
545
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
|
+
}
|
|
546
789
|
case DecorationStyleType.Outline:
|
|
547
790
|
const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
548
791
|
return [
|
|
@@ -551,6 +794,21 @@ class DecorationGroup {
|
|
|
551
794
|
"background-color: transparent !important",
|
|
552
795
|
"box-sizing: border-box !important",
|
|
553
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
|
+
}
|
|
554
812
|
case DecorationStyleType.Highlight:
|
|
555
813
|
default: {
|
|
556
814
|
const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
@@ -572,14 +830,34 @@ class DecorationGroup {
|
|
|
572
830
|
if(item.decoration?.style?.layout === DecorationLayout.Bounds) {
|
|
573
831
|
const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
|
|
574
832
|
bounds.style.setProperty("pointer-events", "none");
|
|
575
|
-
|
|
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);
|
|
576
842
|
itemContainer.append(bounds);
|
|
577
843
|
} else {
|
|
578
|
-
// 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.
|
|
579
856
|
let clientRects = getClientRectsNoOverlap(
|
|
580
|
-
|
|
857
|
+
rectSource,
|
|
581
858
|
true, // doNotMergeHorizontallyAlignedRects
|
|
582
|
-
ctx.isVertical
|
|
859
|
+
ctx.isVertical, // doNotMergeVerticallyAlignedRects
|
|
860
|
+
isLineDecoration ? 0 : expand
|
|
583
861
|
);
|
|
584
862
|
|
|
585
863
|
clientRects = clientRects.sort((r1, r2) => {
|
|
@@ -594,7 +872,23 @@ class DecorationGroup {
|
|
|
594
872
|
for (let clientRect of clientRects) {
|
|
595
873
|
const line = elementTemplate.cloneNode(true) as HTMLDivElement;
|
|
596
874
|
line.style.setProperty("pointer-events", "none");
|
|
597
|
-
|
|
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);
|
|
598
892
|
itemContainer.append(line);
|
|
599
893
|
}
|
|
600
894
|
}
|
|
@@ -623,7 +917,7 @@ class DecorationGroup {
|
|
|
623
917
|
* Returns the group container element, after making sure it exists.
|
|
624
918
|
* @returns Group's container
|
|
625
919
|
*/
|
|
626
|
-
private requireContainer(experimental=false):
|
|
920
|
+
private requireContainer(experimental=false): HTMLStyleElement | HTMLDivElement {
|
|
627
921
|
if (experimental) {
|
|
628
922
|
// Setup <style> for highlights
|
|
629
923
|
let d: HTMLStyleElement;
|
|
@@ -636,15 +930,7 @@ class DecorationGroup {
|
|
|
636
930
|
this.wnd.document.head.appendChild(d);
|
|
637
931
|
}
|
|
638
932
|
|
|
639
|
-
|
|
640
|
-
let h: unknown;
|
|
641
|
-
if (((this.wnd as any).CSS.highlights as Map<string, unknown>).has(this.id)) {
|
|
642
|
-
h = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id)
|
|
643
|
-
} else {
|
|
644
|
-
h = new (this.wnd as any).Highlight();
|
|
645
|
-
((this.wnd as any).CSS.highlights as Map<string, unknown>).set(this.id, h);
|
|
646
|
-
}
|
|
647
|
-
return [d, h];
|
|
933
|
+
return d;
|
|
648
934
|
}
|
|
649
935
|
|
|
650
936
|
if (!this.container) {
|
|
@@ -697,13 +983,7 @@ class DecorationGroup {
|
|
|
697
983
|
|
|
698
984
|
const ctx = makeWritingContext(this.wnd);
|
|
699
985
|
|
|
700
|
-
|
|
701
|
-
if (sML.UA.Blink) {
|
|
702
|
-
const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
|
|
703
|
-
const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
|
|
704
|
-
const effectiveZoom = (rootZoom || 1) * (bodyZoom || 1);
|
|
705
|
-
if (effectiveZoom) iz = 1 / effectiveZoom;
|
|
706
|
-
}
|
|
986
|
+
const iz = 1 / this.effectiveZoom();
|
|
707
987
|
|
|
708
988
|
// Collect all hole rects from mask decorations
|
|
709
989
|
const docEl = this.wnd.document.documentElement;
|
|
@@ -714,12 +994,15 @@ class DecorationGroup {
|
|
|
714
994
|
const style = item.decoration.style as BuiltinDecorationStyle;
|
|
715
995
|
const layout = style.layout ?? DecorationLayout.Boxes;
|
|
716
996
|
const width = style.width ?? DecorationWidth.Wrap;
|
|
997
|
+
const ex = style.expand ?? 0;
|
|
717
998
|
|
|
718
999
|
const boundingRect = item.range.getBoundingClientRect();
|
|
719
1000
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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);
|
|
723
1006
|
|
|
724
1007
|
for (const rect of baseRects) {
|
|
725
1008
|
let hole: DOMRect;
|
|
@@ -739,7 +1022,7 @@ class DecorationGroup {
|
|
|
739
1022
|
break;
|
|
740
1023
|
}
|
|
741
1024
|
default:
|
|
742
|
-
hole = rect;
|
|
1025
|
+
hole = ctx.toRect(ctx.inlineStart(rect), ctx.blockStart(rect), ctx.inlineSize(rect), ctx.blockSize(rect));
|
|
743
1026
|
}
|
|
744
1027
|
allHoleRects.push(hole);
|
|
745
1028
|
}
|
|
@@ -839,7 +1122,12 @@ class DecorationGroup {
|
|
|
839
1122
|
*/
|
|
840
1123
|
private clearContainer() {
|
|
841
1124
|
if (this.experimentalHighlights) {
|
|
842
|
-
(
|
|
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;
|
|
843
1131
|
}
|
|
844
1132
|
this.wnd.document.getElementById(`${this.id}-custom-style`)?.remove();
|
|
845
1133
|
if (this.container) {
|
|
@@ -853,7 +1141,7 @@ export class Decorator extends Module {
|
|
|
853
1141
|
static readonly moduleName: ModuleName = "decorator";
|
|
854
1142
|
private resizeObserver!: ResizeObserver;
|
|
855
1143
|
private styleObserver!: MutationObserver;
|
|
856
|
-
private wnd!:
|
|
1144
|
+
private wnd!: Window;
|
|
857
1145
|
/*private readonly lastSize = {
|
|
858
1146
|
width: 0,
|
|
859
1147
|
height: 0
|
|
@@ -884,7 +1172,7 @@ export class Decorator extends Module {
|
|
|
884
1172
|
}
|
|
885
1173
|
private readonly handleResizer = this.handleResize.bind(this);
|
|
886
1174
|
|
|
887
|
-
mount(wnd:
|
|
1175
|
+
mount(wnd: Window, comms: IComms): boolean {
|
|
888
1176
|
this.wnd = wnd;
|
|
889
1177
|
|
|
890
1178
|
comms.register("decorate", Decorator.moduleName, (data, ack) => {
|
|
@@ -930,6 +1218,15 @@ export class Decorator extends Module {
|
|
|
930
1218
|
ack(true);
|
|
931
1219
|
});
|
|
932
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
|
+
|
|
933
1230
|
this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
|
|
934
1231
|
this.resizeObserver.observe(wnd.document.documentElement);
|
|
935
1232
|
wnd.addEventListener("orientationchange", this.handleResizer);
|
|
@@ -956,7 +1253,7 @@ export class Decorator extends Module {
|
|
|
956
1253
|
return true;
|
|
957
1254
|
}
|
|
958
1255
|
|
|
959
|
-
unmount(wnd:
|
|
1256
|
+
unmount(wnd: Window, comms: IComms): boolean {
|
|
960
1257
|
wnd.removeEventListener("orientationchange", this.handleResizer);
|
|
961
1258
|
wnd.removeEventListener("resize", this.handleResizer);
|
|
962
1259
|
|