-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.ts
417 lines (386 loc) · 12.9 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
/**
* A hierarchy of elements.
*/
export interface ElementTree {
/** The root element of the hierachy. This is typically the <html> element. */
root: Element
/**
* A map, keyed by parent element, of lists of child elements, ordered by
* precedence; earlier children in each list sit above later children.
*/
elementsByParent: Map<Element, Element[]>
}
/**
* A function that returns the initialization properties supplied as the
* `eventInitDict` argument to the constructor of the `MouseEvent` (or
* derivative) class.
*/
export type MouseEventInitDictBuilder = (
originalEvent: MouseEvent
) => MouseEventInit
/**
* Continues an event dispatch to all remaining underlying elements.
*
* This is an event listener that can be installed on an element that contains
* layered child elements that would otherwise not receive the event. It
* assumes that the "default path" of event handling has arrived at the
* element it's attached to via bubbling up, and it should continue to call the
* underlying layers to ensure all elements receive the event.
*
* @param event A `MouseEvent` (or derivative) that bubbled up to a container
* of layered elements.
* @param mouseEventInitDictBuilder See `MouseEventInitDictBuilder` docstring.
* The default supplied clones all properties for `MouseEvent` and known
* derivatives (`DragEvent`, `PointerEvent`, `WheelEvent`). This may be
* overridden to supply subsets or supersets of properties as required.
*/
export function dispatchToUnderlyingElements(
event: MouseEvent,
mouseEventInitDictBuilder: MouseEventInitDictBuilder = getMouseEventInitProperties
) {
if (event.defaultPrevented) return
const { currentTarget, type, x, y } = event
if (!isElement(currentTarget)) {
throw new Error('Expected event.currentTarget to be an Element')
}
// Reduce work by starting from the nearest Shadow DOM if possible.
const dom = findRootNode(currentTarget)
if (!isDocumentOrShadowRoot(dom)) {
throw new Error('Expected root node to be a DocumentOrShadowRoot')
}
const tree = elementTreeFromPoint(x, y, dom)
if (!tree) return
const { elementsByParent } = tree
const subtree = flattenElementTree(elementsByParent, currentTarget)
const defaultPathElementSet = getDefaultPathElementSet(
elementsByParent,
currentTarget
)
const eventInitDict = mouseEventInitDictBuilder(event)
eventInitDict.bubbles = false
for (const element of subtree) {
if (defaultPathElementSet.has(element)) continue
let clone = constructMouseEvent(event, type, eventInitDict)
element.dispatchEvent(clone)
if (clone.defaultPrevented) {
event.preventDefault()
break
}
}
}
/**
* Calculates the set of top-level elements in a hierarchy.
*
* The first sibling of each hierachy level is included in the set.
*
* @param elementsByParent A map, keyed by parent element, of lists of child
* elements, ordered by precedence.
* @param root The root element of `elementsByParent`.
* @returns The Set of first children.
*/
function getDefaultPathElementSet(
elementsByParent: Map<Element, Element[]>,
root: Element
): Set<Element> {
const firstChild = elementsByParent.get(root)?.[0]
const subElements = firstChild
? getDefaultPathElementSet(elementsByParent, firstChild)
: []
const set = new Set<Element>([root, ...subElements])
return set
}
/**
* Converts properties of an `ElementTree` into a flattened form, akin to the
* return type of DocumentOrShadowRoot.elementsFromPoint().
*
* @param elementsByParent A map, keyed by parent element, of lists of child
* elements, ordered by precedence.
* @param root The root element of `elementsByParent`.
* @returns A flattened list of `Element` objects.
*/
export function flattenElementTree(
elementsByParent: Map<Element, Element[]>,
root: Element
): Element[] {
const children = (elementsByParent.get(root) || []).flatMap((child) =>
flattenElementTree(elementsByParent, child)
)
return [...children, root]
}
/**
* Builds a tree of `Element` objects.
*
* Unlike DocumentOrShadowRoot.elementsFromPoint(), which returns a flattened
* list of elements, this function returns a hierarchical structure.
*
* Unlike DocumentOrShadowRoot.elementsFromPoint(), which ignores open Shadow
* DOMs, this function explores open Shadow DOMs.
*
* @param x The horizontal coordinate of a point.
* @param y The vertical coordinate of a point.
* @param topDocumentOrShadowRoot The highest `DocumentOrShadowRoot` to search.
* Elements from above this may be returned, but excess work will not be
* expended in searching unnecessarily there.
* @returns An `ElementTree` structure, or null if there were no elements found
* under the queried coordinates.
*/
export function elementTreeFromPoint(
x: number,
y: number,
topDocumentOrShadowRoot: DocumentOrShadowRoot = document
): ElementTree | null {
let root: Element | null = null
const elementsByParent = new Map<Element, Element[]>()
const processedElements = new Set<Element>()
const processedShadowRootElements = new Set<Element>()
function recurse(
dom: DocumentOrShadowRoot,
higherPotentialParents: Element[]
) {
const elements = engineIndependentElementsFromPoint(x, y, dom)
for (const [i, element] of elements.entries()) {
if (processedElements.has(element)) {
// Don't repeat work. This must continue rather than break because
// there may be shadow DOMs to expand occurring after other elements.
continue
}
if (
hasOpenShadowRoot(element) &&
!processedShadowRootElements.has(element)
) {
// Encountered an unprocessed shadow-DOM-containing element.
processedShadowRootElements.add(element)
recurse(element.shadowRoot, [...higherPotentialParents, element])
} else {
// The set of parents as visible in this DOM.
const potentialParents = new Set(elements.slice(i + 1))
// Step up the visibility chain until a matching parent is found.
let parent: Element | null = element.parentElement
const mutableHigherPotentialParents = [...higherPotentialParents]
while (true) {
if (parent) {
if (potentialParents.has(parent)) {
addToKeyedList(elementsByParent, parent, element)
processedElements.add(element)
break
} else {
parent = parent.parentElement
}
} else {
if (mutableHigherPotentialParents.length) {
parent = mutableHigherPotentialParents.pop()!
} else {
root = element
break // At the top.
}
}
}
}
}
}
recurse(topDocumentOrShadowRoot, [])
return root ? { root, elementsByParent: elementsByParent } : null
}
/**
* Performs DocumentOrShadowRoot.elementsFromPoint() in an engine-independent
* manner.
*
* At the time of writing, the Gecko engine (used in Firefox) has different
* behavior to other engines. As the behavior of the others is marginally more
* useful in the context of this package, this function aligns Gecko's output
* to that of non-Gecko engines.
*
* The salient behavior difference is that when queried from a Shadow DOM,
* Gecko will return elements within that DOM, rather from the <html> element.
* Another minor difference is that when an element with "pointer-events: none"
* styling is queried from its Shadow DOM, it will always be included in the
* resulting list, despite itself not being a potential event target.
*
* @param x The horizontal coordinate of a point.
* @param y The vertical coordinate of a point.
* @param documentOrShadowRoot The `DocumentOrShadowRoot` to search.
* @returns An array of `Element` objects, ordered from the topmost to the
* bottommost box of the viewport.
*/
export function engineIndependentElementsFromPoint(
x: number,
y: number,
documentOrShadowRoot: DocumentOrShadowRoot = document
): Element[] {
const elements = documentOrShadowRoot.elementsFromPoint(x, y)
const lastElement = elements[elements.length - 1]
if (
isShadowRoot(documentOrShadowRoot) &&
lastElement !== document.documentElement
) {
// If we reached here, it's a Gecko engine response from a shadow DOM.
const higherDom = findRootNode(documentOrShadowRoot.host)
if (!isDocumentOrShadowRoot(higherDom)) {
throw new Error('Expected root node to be a DocumentOrShadowRoot')
}
const higherElements = engineIndependentElementsFromPoint(x, y, higherDom)
if (getComputedStyle(documentOrShadowRoot.host).pointerEvents === 'none') {
if (higherElements[0] === documentOrShadowRoot.host) {
// Non-Gecko engines exclude Shadow-DOM-hosting elements when the
// search originates from inside that element's DOM, but includes them
// when originating from outside, such that the caller is informed that
// some element, be it the Shadow-DOM-hosting element itself or an
// element within the Shadow DOM, responds to pointer events. Trimming
// this element mimicks this same behavior for the Gecko engine.
higherElements.splice(0, 1)
}
}
return [...elements, ...higherElements]
} else {
// Non-Gecko engine or empty elements list.
return elements
}
}
function findRootNode(node: Node) {
while (node.parentNode) node = node.parentNode
return node
}
function addToKeyedList<K, V>(keyedList: Map<K, V[]>, parent: K, child: V) {
if (!keyedList.has(parent)) {
keyedList.set(parent, [])
}
keyedList.get(parent)!.push(child)
}
function isElement(value: unknown): value is Element {
return value instanceof Element
}
type ElementWithOpenShadowRoot = Element & { readonly shadowRoot: ShadowRoot }
function hasOpenShadowRoot(value: Element): value is ElementWithOpenShadowRoot {
return !!value.shadowRoot
}
function isShadowRoot(value: unknown): value is ShadowRoot {
return value instanceof ShadowRoot
}
function isDocumentOrShadowRoot(value: unknown): value is DocumentOrShadowRoot {
return value === document || isShadowRoot(value)
}
function getMouseEventInitProperties(event: MouseEvent) {
const {
// EventInit:
bubbles,
cancelable,
composed,
// UIEventInit:
detail,
view,
// EventModifierInit:
altKey,
ctrlKey,
metaKey,
shiftKey,
// MouseEventInit:
button,
buttons,
clientX,
clientY,
movementX,
movementY,
relatedTarget,
screenX,
screenY,
} = event
return {
// EventInit:
bubbles,
cancelable,
composed,
// UIEventInit:
detail,
view,
// EventModifierInit:
altKey,
ctrlKey,
metaKey,
modifierAltGraph: event.getModifierState('AltGraph'),
modifierCapsLock: event.getModifierState('CapsLock'),
modifierFn: event.getModifierState('Fn'),
modifierFnLock: event.getModifierState('FnLock'),
modifierHyper: event.getModifierState('Hyper'),
modifierNumLock: event.getModifierState('NumLock'),
modifierScrollLock: event.getModifierState('ScrollLock'),
modifierSuper: event.getModifierState('Super'),
modifierSymbol: event.getModifierState('Symbol'),
modifierSymbolLock: event.getModifierState('SymbolLock'),
shiftKey,
// MouseEventInit:
button,
buttons,
clientX,
clientY,
movementX,
movementY,
relatedTarget,
screenX,
screenY,
// Specific known sub-types:
...(event instanceof DragEvent ? getDragEventInitProperties(event) : {}),
...(event instanceof PointerEvent
? getPointerEventInitProperties(event)
: {}),
...(event instanceof WheelEvent ? getWheelEventInitProperties(event) : {}),
}
}
function getDragEventInitProperties(event: DragEvent) {
const { dataTransfer } = event
return { dataTransfer }
}
function getPointerEventInitProperties(event: PointerEvent) {
const {
height,
isPrimary,
pointerId,
pointerType,
pressure,
tangentialPressure,
tiltX,
tiltY,
twist,
width,
} = event
const coalescedEvents = event.getCoalescedEvents()
const predictedEvents = event.getPredictedEvents()
return {
coalescedEvents,
height,
isPrimary,
pointerId,
pointerType,
predictedEvents,
pressure,
tangentialPressure,
tiltX,
tiltY,
twist,
width,
}
}
function getWheelEventInitProperties(event: WheelEvent) {
const { deltaMode, deltaX, deltaY, deltaZ } = event
return {
deltaMode,
deltaX,
deltaY,
deltaZ,
}
}
/**
* Instantiates a new `MouseEvent` (or derivative) class.
*
* @param original Class to create a new instance of.
* @param type Event type, e.g. 'click'.
* @param initDict `MouseEventInit` (or derivative) init properties.
* @returns A new `MouseEvent` (or derivative) with the supplied properties.
*/
function constructMouseEvent(
original: MouseEvent,
type: string,
initDict: MouseEventInit
) {
const ctor = original.constructor
return new (ctor.bind.apply(ctor, [null, type, initDict]))()
}