2017-11-08 23:20:41 +00:00
|
|
|
/** Hint links.
|
|
|
|
|
|
|
|
TODO:
|
|
|
|
|
|
|
|
important
|
|
|
|
Connect to input system
|
|
|
|
Gluing into tridactyl
|
|
|
|
unimportant
|
|
|
|
Frames
|
|
|
|
Redraw on reflow
|
|
|
|
*/
|
|
|
|
|
|
|
|
import {elementsByXPath, isVisible, mouseEvent} from './dom'
|
|
|
|
import {log} from './math'
|
|
|
|
import {permutationsWithReplacement, islice, izip, map} from './itertools'
|
|
|
|
import {hasModifiers} from './keyseq'
|
2017-11-09 00:41:07 +00:00
|
|
|
import state from './state'
|
2017-11-08 23:20:41 +00:00
|
|
|
|
|
|
|
/** Simple container for the state of a single frame's hints. */
|
|
|
|
class HintState {
|
|
|
|
public focusedHint: Hint
|
|
|
|
readonly hintHost = document.createElement('div')
|
|
|
|
readonly hints: Hint[] = []
|
|
|
|
public filter = ''
|
2017-11-09 00:41:07 +00:00
|
|
|
public hintchars = ''
|
2017-11-08 23:20:41 +00:00
|
|
|
|
|
|
|
destructor() {
|
|
|
|
// Undo any alterations of the hinted elements
|
|
|
|
for (const hint of this.hints) {
|
|
|
|
hint.hidden = true
|
|
|
|
}
|
|
|
|
|
|
|
|
// Remove all hints from the DOM.
|
|
|
|
this.hintHost.remove()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let modeState: HintState = undefined
|
|
|
|
|
|
|
|
/** For each hintable element, add a hint */
|
|
|
|
export function hintPage(hintableElements: Element[], onSelect: HintSelectedCallback) {
|
|
|
|
modeState = new HintState()
|
|
|
|
for (let [el, name] of izip(hintableElements, hintnames())) {
|
2017-11-09 00:41:07 +00:00
|
|
|
modeState.hintchars += name
|
2017-11-08 23:20:41 +00:00
|
|
|
modeState.hints.push(new Hint(el, name, onSelect))
|
|
|
|
}
|
2017-11-09 00:41:07 +00:00
|
|
|
console.log("HINTS", modeState.hints)
|
2017-11-08 23:20:41 +00:00
|
|
|
modeState.focusedHint = modeState.hints[0]
|
2017-11-09 00:41:07 +00:00
|
|
|
modeState.focusedHint.focused = true
|
2017-11-08 23:20:41 +00:00
|
|
|
document.body.appendChild(modeState.hintHost)
|
|
|
|
}
|
|
|
|
|
|
|
|
/** vimperator-style minimal hint names */
|
|
|
|
function* hintnames(hintchars = HINTCHARS) {
|
|
|
|
let taglen = 1
|
|
|
|
while (true) {
|
|
|
|
yield* map(permutationsWithReplacement(hintchars, taglen), e=>e.join(''))
|
|
|
|
taglen++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type HintSelectedCallback = (Hint) => any
|
|
|
|
|
|
|
|
/** Place a flag by each hintworthy element */
|
|
|
|
class Hint {
|
|
|
|
private readonly flag = document.createElement('span')
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
private readonly target: Element,
|
|
|
|
public readonly name: string,
|
|
|
|
private readonly onSelect: HintSelectedCallback
|
|
|
|
) {
|
|
|
|
const rect = target.getClientRects()[0]
|
|
|
|
this.flag.textContent = name
|
2017-11-09 00:41:07 +00:00
|
|
|
this.flag.className = 'TridactylHint'
|
|
|
|
/* this.flag.style.cssText = ` */
|
|
|
|
/* top: ${rect.top}px; */
|
|
|
|
/* left: ${rect.left}px; */
|
|
|
|
/* ` */
|
2017-11-08 23:20:41 +00:00
|
|
|
this.flag.style.cssText = `
|
2017-11-09 00:41:07 +00:00
|
|
|
top: ${window.scrollY + rect.top}px;
|
|
|
|
left: ${window.scrollX + rect.left}px;
|
2017-11-08 23:20:41 +00:00
|
|
|
`
|
|
|
|
modeState.hintHost.appendChild(this.flag)
|
2017-11-09 00:41:07 +00:00
|
|
|
target.classList.add('TridactylHintElem')
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// These styles would be better with pseudo selectors. Can we do custom ones?
|
|
|
|
// If not, do a state machine.
|
|
|
|
set hidden(hide: boolean) {
|
|
|
|
this.flag.hidden = hide
|
|
|
|
if (hide) {
|
|
|
|
this.focused = false
|
2017-11-09 00:41:07 +00:00
|
|
|
this.target.classList.remove('TridactylHintElem')
|
2017-11-08 23:20:41 +00:00
|
|
|
} else
|
2017-11-09 00:41:07 +00:00
|
|
|
this.target.classList.add('TridactylHintElem')
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
set focused(focus: boolean) {
|
|
|
|
if (focus) {
|
2017-11-09 00:41:07 +00:00
|
|
|
this.target.classList.add('TridactylHintActive')
|
|
|
|
this.target.classList.remove('TridactylHintElem')
|
2017-11-08 23:20:41 +00:00
|
|
|
} else {
|
2017-11-09 00:41:07 +00:00
|
|
|
this.target.classList.add('TridactylHintElem')
|
|
|
|
this.target.classList.remove('TridactylHintActive')
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
select() {
|
|
|
|
this.onSelect(this)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Uniform length hintnames */
|
|
|
|
function* hintnames_uniform(n: number, hintchars = HINTCHARS) {
|
|
|
|
if (n <= hintchars.length)
|
|
|
|
yield* islice(hintchars[Symbol.iterator](), n)
|
|
|
|
else {
|
|
|
|
// else calculate required length of each tag
|
|
|
|
const taglen = Math.ceil(log(n, hintchars.length))
|
|
|
|
// And return first n permutations
|
|
|
|
yield* islice(permutationsWithReplacement(hintchars, taglen), n)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-11-09 00:41:07 +00:00
|
|
|
const HINTCHARS = 'hjklasdfgyuiopqwertnmzxcvb'
|
|
|
|
/* const HINTCHARS = 'asdf' */
|
2017-11-08 23:20:41 +00:00
|
|
|
|
2017-11-09 00:41:07 +00:00
|
|
|
/** Show only hints prefixed by fstr. Focus first match */
|
2017-11-08 23:20:41 +00:00
|
|
|
function filter(fstr) {
|
2017-11-09 00:41:07 +00:00
|
|
|
console.log(fstr)
|
2017-11-08 23:20:41 +00:00
|
|
|
const active: Hint[] = []
|
|
|
|
let foundMatch
|
|
|
|
for (let h of modeState.hints) {
|
|
|
|
if (!h.name.startsWith(fstr)) h.hidden = true
|
|
|
|
else {
|
|
|
|
if (! foundMatch) {
|
|
|
|
h.focused = true
|
|
|
|
modeState.focusedHint = h
|
|
|
|
foundMatch = true
|
|
|
|
}
|
2017-11-09 00:41:07 +00:00
|
|
|
h.hidden = false
|
2017-11-08 23:20:41 +00:00
|
|
|
active.push(h)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
2017-11-09 00:41:07 +00:00
|
|
|
if (active.length == 1) {
|
|
|
|
selectFocusedHint()
|
|
|
|
}
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Remove all hints, reset STATE. */
|
|
|
|
function reset() {
|
|
|
|
modeState.destructor()
|
|
|
|
modeState = undefined
|
|
|
|
}
|
|
|
|
|
|
|
|
/** If key is in hintchars, add it to filtstr and filter */
|
|
|
|
function pushKey(ke) {
|
|
|
|
if (hasModifiers(ke)) {
|
|
|
|
return
|
|
|
|
} else if (ke.key === 'Backspace') {
|
|
|
|
modeState.filter = modeState.filter.slice(0,-1)
|
|
|
|
filter(modeState.filter)
|
|
|
|
} else if (ke.key.length > 1) {
|
|
|
|
return
|
2017-11-09 00:41:07 +00:00
|
|
|
} else if (modeState.hintchars.includes(ke.key)) {
|
2017-11-08 23:20:41 +00:00
|
|
|
modeState.filter += ke.key
|
|
|
|
filter(modeState.filter)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Array of hintable elements in viewport
|
|
|
|
|
|
|
|
Elements are hintable if
|
|
|
|
1. they can be meaningfully selected, clicked, etc
|
|
|
|
2. they're visible
|
|
|
|
1. Within viewport
|
|
|
|
2. Not hidden by another element
|
|
|
|
*/
|
|
|
|
function hintables() {
|
2017-11-12 01:14:45 +00:00
|
|
|
/* return [...elementsByXPath(HINTTAGS)].filter(isVisible) as any as Element[] */
|
|
|
|
return Array.from(document.querySelectorAll(HINTTAGS_selectors)).filter(isVisible)
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
|
2017-11-12 01:14:45 +00:00
|
|
|
// XPath. Doesn't work properly for xhtml unless you double each element.
|
2017-11-08 23:20:41 +00:00
|
|
|
const HINTTAGS = `
|
|
|
|
//input[not(@type='hidden' or @disabled)] |
|
|
|
|
//a |
|
|
|
|
//area |
|
|
|
|
//iframe |
|
|
|
|
//textarea |
|
|
|
|
//button |
|
|
|
|
//select |
|
|
|
|
//*[
|
|
|
|
@onclick or
|
|
|
|
@onmouseover or
|
|
|
|
@onmousedown or
|
|
|
|
@onmouseup or
|
|
|
|
@oncommand or
|
|
|
|
@role='link'or
|
|
|
|
@role='button' or
|
|
|
|
@role='checkbox' or
|
|
|
|
@role='combobox' or
|
|
|
|
@role='listbox' or
|
|
|
|
@role='listitem' or
|
|
|
|
@role='menuitem' or
|
|
|
|
@role='menuitemcheckbox' or
|
|
|
|
@role='menuitemradio' or
|
|
|
|
@role='option' or
|
|
|
|
@role='radio' or
|
|
|
|
@role='scrollbar' or
|
|
|
|
@role='slider' or
|
|
|
|
@role='spinbutton' or
|
|
|
|
@role='tab' or
|
|
|
|
@role='textbox' or
|
|
|
|
@role='treeitem' or
|
|
|
|
@tabindex
|
|
|
|
]`
|
|
|
|
|
2017-11-12 01:14:45 +00:00
|
|
|
// CSS selectors. More readable for web developers. Not dead. Leaves browser to care about XML.
|
|
|
|
const HINTTAGS_selectors = `
|
|
|
|
input:not([type=hidden]):not([disabled]),
|
|
|
|
a,
|
|
|
|
area,
|
|
|
|
iframe,
|
|
|
|
textarea,
|
|
|
|
button,
|
|
|
|
select,
|
|
|
|
[onclick],
|
|
|
|
[onmouseover],
|
|
|
|
[onmousedown],
|
|
|
|
[onmouseup],
|
|
|
|
[oncommand],
|
|
|
|
[role='link'],
|
|
|
|
[role='button'],
|
|
|
|
[role='checkbox'],
|
|
|
|
[role='combobox'],
|
|
|
|
[role='listbox'],
|
|
|
|
[role='listitem'],
|
|
|
|
[role='menuitem'],
|
|
|
|
[role='menuitemcheckbox'],
|
|
|
|
[role='menuitemradio'],
|
|
|
|
[role='option'],
|
|
|
|
[role='radio'],
|
|
|
|
[role='scrollbar'],
|
|
|
|
[role='slider'],
|
|
|
|
[role='spinbutton'],
|
|
|
|
[role='tab'],
|
|
|
|
[role='textbox'],
|
|
|
|
[role='treeitem'],
|
|
|
|
[tabindex]
|
|
|
|
`
|
|
|
|
|
2017-11-18 01:51:46 +00:00
|
|
|
import browserBg from './lib/browser_proxy'
|
|
|
|
|
|
|
|
function hintPageOpenInBackground() {
|
|
|
|
hintPage(hintables(), hint=>{
|
|
|
|
hint.target.focus()
|
|
|
|
if (hint.target.href && ! hint.target.href.startsWith('#')) {
|
|
|
|
browserBg.tabs.create({
|
|
|
|
active: false,
|
|
|
|
url: hint.target.href
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
// This is to mirror vimperator behaviour.
|
|
|
|
mouseEvent(hint.target, "click")
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
2017-11-08 23:20:41 +00:00
|
|
|
|
2017-11-09 00:41:07 +00:00
|
|
|
function hintPageSimple() {
|
|
|
|
console.log("Hinting!")
|
2017-11-09 21:30:28 +00:00
|
|
|
hintPage(hintables(), hint=>{
|
|
|
|
hint.target.focus()
|
|
|
|
mouseEvent(hint.target, 'click')
|
|
|
|
})
|
2017-11-09 00:41:07 +00:00
|
|
|
}
|
|
|
|
|
2017-11-08 23:20:41 +00:00
|
|
|
function selectFocusedHint() {
|
2017-11-09 00:41:07 +00:00
|
|
|
console.log("Selecting hint.", state.mode)
|
|
|
|
state.mode = 'normal'
|
2017-11-08 23:20:41 +00:00
|
|
|
modeState.focusedHint.select()
|
2017-11-09 00:41:07 +00:00
|
|
|
reset()
|
2017-11-08 23:20:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
import {addListener, attributeCaller} from './messaging'
|
2017-11-09 00:41:07 +00:00
|
|
|
addListener('hinting_content', attributeCaller({
|
|
|
|
pushKey,
|
|
|
|
selectFocusedHint,
|
|
|
|
reset,
|
|
|
|
hintPageSimple,
|
2017-11-18 01:51:46 +00:00
|
|
|
hintPageOpenInBackground,
|
2017-11-09 00:41:07 +00:00
|
|
|
}))
|