2017-10-23 09:42:50 +01:00
|
|
|
import {MsgSafeNode} from './msgsafe'
|
|
|
|
// From saka-key lib/dom.js, under Apachev2
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a DOM element, returns true if you can edit it with key presses or
|
|
|
|
* if the element is of a type that should handle its own keypresses
|
|
|
|
* (e.g. role=application for google docs/sheets)
|
|
|
|
* TODO: work on case sensitivity
|
|
|
|
* consider all the possible cases
|
|
|
|
* @param {HTMLElement} element
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
export function isTextEditable (element: MsgSafeNode) {
|
2017-11-09 00:41:07 +00:00
|
|
|
if (element) {
|
|
|
|
switch (element.nodeName) {
|
|
|
|
case 'INPUT':
|
|
|
|
return isEditableHTMLInput(element)
|
|
|
|
case 'TEXTAREA':
|
|
|
|
case 'OBJECT':
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
switch (true) {
|
|
|
|
case element.contentEditable.toUpperCase() === 'TRUE':
|
|
|
|
case element.role === 'application':
|
|
|
|
return true
|
|
|
|
}
|
2017-10-23 09:42:50 +01:00
|
|
|
}
|
2017-11-09 00:41:07 +00:00
|
|
|
return false
|
2017-10-23 09:42:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns whether the passed HTML input element is editable
|
|
|
|
* @param {HTMLInputElement} element
|
|
|
|
*/
|
|
|
|
function isEditableHTMLInput (element: MsgSafeNode) {
|
2017-11-09 00:41:07 +00:00
|
|
|
if (element.disabled || element.readonly) return false
|
|
|
|
switch (element.type) {
|
|
|
|
case undefined:
|
|
|
|
case 'text':
|
|
|
|
case 'search':
|
|
|
|
case 'email':
|
|
|
|
case 'url':
|
|
|
|
case 'number':
|
|
|
|
case 'password':
|
|
|
|
case 'date':
|
|
|
|
case 'tel':
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return false
|
2017-10-23 09:42:50 +01:00
|
|
|
}
|
2017-11-08 23:20:41 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Dispatch a mouse event to the target element
|
|
|
|
* based on cVim's implementation
|
|
|
|
* @param {HTMLElement} element
|
|
|
|
* @param {'hover' | 'unhover' | 'click'} type
|
|
|
|
* @param {{ ctrlKey, shiftKey, altKey, metaKey }} modifierKeys
|
|
|
|
*/
|
|
|
|
export function mouseEvent (element: Element, type: 'hover'|'unhover'|'click', modifierKeys = {}) {
|
2017-11-09 00:41:07 +00:00
|
|
|
let events
|
|
|
|
switch (type) {
|
|
|
|
case 'hover':
|
|
|
|
events = ['mouseover', 'mouseenter', 'mousemove']
|
|
|
|
break
|
|
|
|
case 'unhover':
|
|
|
|
events = ['mousemove', 'mouseout', 'mouseleave']
|
|
|
|
break
|
|
|
|
case 'click':
|
|
|
|
events = ['mouseover', 'mousedown', 'mouseup', 'click']
|
|
|
|
break
|
|
|
|
}
|
|
|
|
events.forEach(type => {
|
|
|
|
const event = new MouseEvent(type, {
|
|
|
|
bubbles: true,
|
|
|
|
cancelable: true,
|
|
|
|
view: window,
|
|
|
|
detail: 1, // usually the click count
|
|
|
|
...modifierKeys
|
|
|
|
})
|
|
|
|
element.dispatchEvent(event)
|
2017-11-08 23:20:41 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Iterable of elements that match xpath.
|
|
|
|
|
|
|
|
Adapted from stackoverflow
|
2017-11-09 00:41:07 +00:00
|
|
|
*/
|
2017-11-08 23:20:41 +00:00
|
|
|
export function* elementsByXPath(xpath, parent?)
|
|
|
|
{
|
2017-11-09 00:41:07 +00:00
|
|
|
let query = document.evaluate(xpath,
|
|
|
|
parent || document,
|
|
|
|
null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
|
|
for (let i=0, length=query.snapshotLength; i<length; ++i) {
|
|
|
|
yield query.snapshotItem(i);
|
|
|
|
}
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
|
2017-11-23 23:57:04 +00:00
|
|
|
/** Type for functions that can filter element arrays */
|
|
|
|
interface ElementFilter { (element: Element): boolean }
|
|
|
|
|
|
|
|
/** Is the element of "substantial" size and shown on the page. The element
|
|
|
|
* doesn't need to be in the viewport. This is useful when you want to
|
|
|
|
* scroll to something, but still want to exclude tiny and useless items
|
|
|
|
*/
|
|
|
|
export function isSubstantial (element: Element) {
|
|
|
|
const clientRect = element.getClientRects()[0]
|
|
|
|
const computedStyle = getComputedStyle(element)
|
|
|
|
// remove elements that are barely within the viewport, tiny, or invisible
|
|
|
|
switch (true) {
|
|
|
|
case !clientRect:
|
|
|
|
case clientRect.width < 3:
|
|
|
|
case clientRect.height < 3:
|
|
|
|
case computedStyle.visibility !== 'visible':
|
|
|
|
case computedStyle.display === 'none':
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
2017-11-08 23:20:41 +00:00
|
|
|
|
|
|
|
// Saka-key caches getComputedStyle. Maybe it's a good idea!
|
|
|
|
/* let cgetComputedStyle = cacheDecorator(getComputedStyle) */
|
|
|
|
|
|
|
|
/** is the element within a rect and not obscured by another element?
|
|
|
|
|
|
|
|
From: https://github.com/lusakasa/saka-key/blob/9f560b3a718a9efda809dcb794de14b4e675b35a/src/modes/hints/client/findHints.js#L97
|
|
|
|
Based on https://github.com/guyht/vimari/blob/master/vimari.safariextension/linkHints.js
|
|
|
|
|
2017-11-09 00:41:07 +00:00
|
|
|
*/
|
2017-11-08 23:20:41 +00:00
|
|
|
export function isVisible (element: Element) {
|
|
|
|
const clientRect = element.getClientRects()[0]
|
|
|
|
const computedStyle = getComputedStyle(element)
|
|
|
|
// remove elements that are barely within the viewport, tiny, or invisible
|
|
|
|
switch (true) {
|
|
|
|
case !clientRect:
|
|
|
|
case clientRect.top < 0:
|
|
|
|
case clientRect.top >= innerHeight - 4:
|
|
|
|
case clientRect.left < 0:
|
|
|
|
case clientRect.left >= innerWidth - 4:
|
|
|
|
case clientRect.width < 3:
|
|
|
|
case clientRect.height < 3:
|
|
|
|
case computedStyle.visibility !== 'visible':
|
|
|
|
case computedStyle.display === 'none':
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
2017-11-09 00:41:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
/* // Eliminate elements hidden by another overlapping element. */
|
|
|
|
/* // To do that, get topmost element at some offset from upper-left corner of clientRect */
|
|
|
|
/* // and check whether it is the element itself or one of its descendants. */
|
|
|
|
/* // The offset is needed to account for coordinates truncation and elements with rounded borders. */
|
|
|
|
/* // */
|
|
|
|
/* // Coordinates truncation occcurs when using zoom. In that case, clientRect coords should be float, */
|
|
|
|
/* // but we get integers instead. That makes so that elementFromPoint(clientRect.left, clientRect.top) */
|
|
|
|
/* // sometimes returns an element different from the one clientRect was obtained from. */
|
|
|
|
/* // So we introduce an offset to make sure elementFromPoint hits the right element. */
|
|
|
|
/* // */
|
|
|
|
/* // For elements with a rounded topleft border, the upper left corner lies outside the element. */
|
|
|
|
/* // Then, we need an offset to get to the point nearest to the upper left corner, but within border. */
|
|
|
|
/* const coordTruncationOffset = 2 // A value of 1 has been observed not to be enough, */
|
|
|
|
/* // so we heuristically choose 2, which seems to work well. */
|
|
|
|
/* // We know a value of 2 is still safe (lies within the element) because, */
|
|
|
|
/* // from the code above, widht & height are >= 3. */
|
|
|
|
/* const radius = parseFloat(computedStyle.borderTopLeftRadius) */
|
|
|
|
/* const roundedBorderOffset = Math.ceil(radius * (1 - Math.sin(Math.PI / 4))) */
|
|
|
|
/* const offset = Math.max(coordTruncationOffset, roundedBorderOffset) */
|
|
|
|
/* if (offset >= clientRect.width || offset >= clientRect.height) { */
|
|
|
|
/* return false */
|
|
|
|
/* } */
|
|
|
|
/* let el: Node = document.elementFromPoint( */
|
|
|
|
/* clientRect.left + offset, */
|
|
|
|
/* clientRect.top + offset */
|
|
|
|
/* ) */
|
|
|
|
/* while (el && el !== element) { */
|
|
|
|
/* el = el.parentNode */
|
|
|
|
/* } */
|
|
|
|
/* if (!el) { */
|
|
|
|
/* return false */
|
|
|
|
/* } */
|
|
|
|
/* return true */
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
2017-11-23 23:57:04 +00:00
|
|
|
|
|
|
|
/** Get all elements that match the given selector
|
|
|
|
*
|
|
|
|
* @param selector `the CSS selector to choose elements with
|
|
|
|
* @param filter filter to use to further chose items, or null for all
|
|
|
|
*/
|
|
|
|
export function getElemsBySelector(selector: string, filter: ElementFilter) {
|
|
|
|
let elems = Array.from(document.querySelectorAll(selector))
|
|
|
|
|
|
|
|
return filter ? elems.filter(filter) : elems
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Get the nth input element on a page
|
|
|
|
*
|
|
|
|
* @param nth the element index, can be negative to start at the end
|
|
|
|
* @param filter filter to use to further chose items, or null for all
|
|
|
|
*/
|
|
|
|
export function getNthElement(selectors: string, nth: number,
|
|
|
|
filter: ElementFilter) {
|
|
|
|
|
|
|
|
let inputs = getElemsBySelector(selectors, filter)
|
|
|
|
|
|
|
|
if (inputs.length) {
|
|
|
|
let index = Number(nth).clamp(-inputs.length, inputs.length - 1)
|
|
|
|
.mod(inputs.length)
|
|
|
|
|
|
|
|
return <HTMLElement>inputs[index]
|
|
|
|
}
|
|
|
|
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Comparison function by offsetWidth/Height, used for sorting elements by their
|
|
|
|
* area on the page
|
|
|
|
*/
|
|
|
|
export function compareElementArea(a: HTMLElement, b: HTMLElement): number {
|
|
|
|
const aArea = a.offsetWidth * a.offsetHeight
|
|
|
|
const bArea = b.offsetWidth * b.offsetHeight
|
|
|
|
|
|
|
|
return aArea - bArea
|
|
|
|
}
|