/** Hint links. TODO: important Connect to input system Gluing into tridactyl unimportant Frames Redraw on reflow */ import * as DOM from './dom' import {log} from './math' import {permutationsWithReplacement, islice, izip, map, unique} from './itertools' import {hasModifiers} from './keyseq' import state from './state' import {messageActiveTab, message} from './messaging' import * as config from './config' import * as TTS from './text_to_speech' import {HintSaveType} from './hinting_background' import Logger from './logging' const logger = new Logger('hinting') /** Simple container for the state of a single frame's hints. */ class HintState { public focusedHint: Hint readonly hintHost = html`
` readonly hints: Hint[] = [] public filter = '' public hintchars = '' constructor(public filterFunc: HintFilter) {} 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, buildHints: HintBuilder = defaultHintBuilder(), filterHints: HintFilter = defaultHintFilter(), ) { state.mode = 'hint' modeState = new HintState(filterHints) buildHints(hintableElements, onSelect) if (modeState.hints.length) { logger.debug("hints", modeState.hints) modeState.focusedHint = modeState.hints[0] modeState.focusedHint.focused = true document.documentElement.appendChild(modeState.hintHost) } else { reset() } } function defaultHintBuilder() { switch (config.get('hintfiltermode')) { case 'simple': return buildHintsSimple case 'vimperator': return buildHintsVimperator case 'vimperator-reflow': return buildHintsVimperator } } function defaultHintFilter() { switch (config.get('hintfiltermode')) { case 'simple': return filterHintsSimple case 'vimperator': return filterHintsVimperator case 'vimperator-reflow': return (fstr) => filterHintsVimperator(fstr, true) } } /** An infinite stream of hints Earlier hints prefix later hints */ function* hintnames_simple(hintchars = config.get("hintchars")): IterableIterator { for (let taglen = 1; true; taglen++) { yield* map( permutationsWithReplacement(hintchars, taglen), e => e.join('') ) } } /** Shorter hints Hints that are prefixes of other hints are a bit annoying because you have to select them with Enter or Space. This function removes hints that prefix other hints by observing that: let h = hintchars.length if n < h ** 2 then n / h = number of single character hintnames that would prefix later hints So it removes them. This function is not yet clever enough to realise that if n > h ** 2 it should remove h + (n - h**2 - h) / h ** 2 and so on, but we hardly ever see that many hints, so whatever. */ function* hintnames(n: number, hintchars = config.get("hintchars")): IterableIterator { let source = hintnames_simple(hintchars) const num2skip = Math.floor(n / hintchars.length) yield* islice(source, num2skip, n + num2skip) } /** Uniform length hintnames */ function* hintnames_uniform(n: number, hintchars = config.get("hintchars")): IterableIterator { 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* map(islice(permutationsWithReplacement(hintchars, taglen), n), perm => { return perm.join('') }) } } type HintSelectedCallback = (Hint) => any /** Place a flag by each hintworthy element */ class Hint { public readonly flag = document.createElement('span') constructor( private readonly target: Element, public readonly name: string, public readonly filterData: any, private readonly onSelect: HintSelectedCallback ) { const rect = target.getClientRects()[0] this.flag.textContent = name this.flag.className = 'TridactylHint' /* this.flag.style.cssText = ` */ /* top: ${rect.top}px; */ /* left: ${rect.left}px; */ /* ` */ this.flag.style.cssText = ` top: ${window.scrollY + rect.top}px; left: ${window.scrollX + rect.left}px; ` modeState.hintHost.appendChild(this.flag) target.classList.add('TridactylHintElem') } // 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 this.target.classList.remove('TridactylHintElem') } else this.target.classList.add('TridactylHintElem') } set focused(focus: boolean) { if (focus) { this.target.classList.add('TridactylHintActive') this.target.classList.remove('TridactylHintElem') } else { this.target.classList.add('TridactylHintElem') this.target.classList.remove('TridactylHintActive') } } select() { this.onSelect(this) } } type HintBuilder = (els: Element[], onSelect: HintSelectedCallback) => void function buildHintsSimple(els: Element[], onSelect: HintSelectedCallback) { let names = hintnames(els.length) for (let [el, name] of izip(els, names)) { logger.debug({el, name}) modeState.hintchars += name modeState.hints.push(new Hint(el, name, null, onSelect)) } } function buildHintsVimperator(els: Element[], onSelect: HintSelectedCallback) { let names = hintnames(els.length) // escape the hintchars string so that strange things don't happen // when special characters are used as hintchars (for example, ']') const escapedHintChars = config.get('hintchars').replace(/^\^|[-\\\]]/g, "\\$&") const filterableTextFilter = new RegExp('[' + escapedHintChars + ']', 'gi') for (let [el, name] of izip(els, names)) { let ft = elementFilterableText(el) // strip out hintchars ft = ft.replace(filterableTextFilter, '') logger.debug({el, name, ft}) modeState.hintchars += name + ft modeState.hints.push(new Hint(el, name, ft, onSelect)) } } function elementFilterableText(el: Element): string { const nodename = el.nodeName.toLowerCase() let text: string if (nodename == 'input') { text = (el).value } else if (0 < el.textContent.length) { text = el.textContent } else if (el.hasAttribute('title')) { text = el.getAttribute('title') } else { text = el.innerHTML } // Truncate very long text values return text.slice(0,2048).toLowerCase() || '' } type HintFilter = (string) => void /** Show only hints prefixed by fstr. Focus first match */ function filterHintsSimple(fstr) { 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 } h.hidden = false active.push(h) } } if (active.length == 1) { selectFocusedHint() } } /** Partition the filter string into hintchars and content filter strings. Apply each part in sequence, reducing the list of active hints. Update display after all filtering, adjusting labels if appropriate. Consider: This is a poster child for separating data and display. If they weren't so tied here we could do a neat dynamic programming thing and just throw the data at a reactalike. */ function filterHintsVimperator(fstr, reflow=false) { /** Partition a fstr into a tagged array of substrings */ function partitionFstr(fstr): {str: string, isHintChar: boolean}[] { const peek = (a) => a[a.length - 1] const hintChars = config.get('hintchars') // For each char, either add it to the existing run if there is one and // it's a matching type or start a new run const runs = [] for (const char of fstr) { const isHintChar = hintChars.includes(char) if (! peek(runs) || peek(runs).isHintChar !== isHintChar) { runs.push({str: char, isHintChar}) } else { peek(runs).str += char } } return runs } function rename(hints) { const names = hintnames(hints.length) for (const [hint, name] of izip(hints, names)) { hint.name = name } } // Start with all hints let active = modeState.hints // If we're reflowing, the names may be wrong at this point, so apply the original names. if (reflow) rename(active) // Filter down (renaming as required) for (const run of partitionFstr(fstr)) { if (run.isHintChar) { // Filter by label active = active.filter(hint => hint.name.startsWith(run.str)) } else { // By text active = active.filter(hint => hint.filterData.includes(run.str)) } if (reflow && ! run.isHintChar) { rename(active) } } // Update display // Hide all hints for (const hint of modeState.hints) { // Warning: this could cause flickering. hint.hidden = true } // Show and update labels of active for (const hint of active) { hint.hidden = false hint.flag.textContent = hint.name } // Focus first hint if (active.length) { if (modeState.focusedHint) { modeState.focusedHint.focused = false } active[0].focused = true modeState.focusedHint = active[0] } // Select focused hint if it's the only match if (active.length == 1) { selectFocusedHint() } } /** Remove all hints, reset STATE. */ function reset() { modeState.destructor() modeState = undefined state.mode = 'normal' } /** 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) modeState.filterFunc(modeState.filter) } else if (ke.key.length > 1) { return } else if (modeState.hintchars.includes(ke.key)) { modeState.filter += ke.key modeState.filterFunc(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(selectors=HINTTAGS_selectors) { let elems = DOM.getElemsBySelector(selectors, []) elems = elems.concat(DOM.hintworthy_js_elems) elems = unique(elems) return elems.filter(DOM.isVisible) } function elementswithtext() { return DOM.getElemsBySelector("*", [DOM.isVisible, hint => { return hint.textContent != "" }] ) } /** Returns elements that point to a saveable resource */ function saveableElements() { return DOM.getElemsBySelector(HINTTAGS_saveable, [DOM.isVisible]) } /** Get array of images in the viewport */ function hintableImages() { return DOM.getElemsBySelector(HINTTAGS_img_selectors, [DOM.isVisible]) } /** Get arrat of "anchors": elements which have id or name and can be addressed * with the hash/fragment in the URL */ function anchors() { return DOM.getElemsBySelector(HINTTAGS_anchor_selectors, [DOM.isVisible]) } /** Array of items that can be killed with hint kill */ function killables() { return DOM.getElemsBySelector(HINTTAGS_killable_selectors, [DOM.isVisible]) } // 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, summary, [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'], [class*='button'], [tabindex] ` const HINTTAGS_img_selectors = ` img, [src] ` const HINTTAGS_anchor_selectors = ` [id], [name] ` const HINTTAGS_killable_selectors = ` span, div, iframe, img, button, article, summary ` /** CSS selector for elements which point to a saveable resource */ const HINTTAGS_saveable = ` [href]:not([href='#']) ` import {openInNewTab} from './lib/webext' /** if `target === _blank` clicking the link is treated as opening a popup and is blocked. Use webext API to avoid that. */ function simulateClick(target: HTMLElement) { // target can be set to other stuff, and we'll fail in annoying ways. // There's no easy way around that while this code executes outside of the // magic 'short lived event handler' context. // // OTOH, hardly anyone uses that functionality any more. if ((target as HTMLAnchorElement).target === '_blank' || (target as HTMLAnchorElement).target === '_new' ) { openInNewTab((target as HTMLAnchorElement).href) } else { DOM.mouseEvent(target, "click") // Sometimes clicking the element doesn't focus it sufficiently. target.focus() } } function hintPageOpenInBackground() { hintPage(hintables(), hint=>{ hint.target.focus() if (hint.target.href) { // Try to open with the webext API. If that fails, simulate a click on this page anyway. openInNewTab(hint.target.href, false).catch(()=>simulateClick(hint.target)) } else { // This is to mirror vimperator behaviour. simulateClick(hint.target) } }) } function hintPageSimple(selectors=HINTTAGS_selectors) { hintPage(hintables(selectors), hint=>{ simulateClick(hint.target) }) } function hintPageTextYank() { hintPage(elementswithtext(), hint=>{ messageActiveTab("commandline_frame", "setClipboard", [hint.target.textContent]) }) } function hintPageYank() { hintPage(hintables(), hint=>{ messageActiveTab("commandline_frame", "setClipboard", [hint.target.href]) }) } /** Hint anchors and yank the URL on selection */ function hintPageAnchorYank() { hintPage(anchors(), hint=>{ let anchorUrl = new URL(window.location.href) anchorUrl.hash = hint.target.id || hint.target.name; messageActiveTab("commandline_frame", "setClipboard", [anchorUrl.href]) }) } /** Hint images, opening in the same tab, or in a background tab * * @param inBackground opens the image source URL in a background tab, * as opposed to the current tab */ function hintImage(inBackground) { hintPage(hintableImages(), hint=>{ let img_src = hint.target.getAttribute("src") if (inBackground) { openInNewTab(new URL(img_src, window.location.href).href, false) } else { window.location.href = img_src } }) } /** Hint elements to focus */ function hintFocus() { hintPage(hintables(), hint=>{ hint.target.focus() }) } /** Hint items and read out the content of the selection */ function hintRead() { hintPage(elementswithtext(), hint=>{ TTS.readText(hint.target.textContent) }) } /** Hint elements and delete the selection from the page */ function hintKill() { hintPage(killables(), hint=>{ hint.target.remove(); }) } /** Hint link elements to save * * @param hintType the type of elements to hint and save: * - "link": elements that point to another resource (eg * links to pages/files) - the link targer is saved * - "img": image elements * @param saveAs prompt for save location */ function hintSave(hintType: HintSaveType, saveAs: boolean) { function saveHintElems(hintType) { return (hintType === "link") ? saveableElements() : hintableImages() } function urlFromElem(hintType, elem) { return (hintType === "link") ? elem.href : elem.src } hintPage(saveHintElems(hintType), hint=>{ const urlToSave = new URL(urlFromElem(hintType, hint.target), window.location.href) // Pass to background context to allow saving from data URLs. // Convert to href because can't clone URL across contexts message('download_background', "downloadUrl", [urlToSave.href, saveAs]) }) } function selectFocusedHint() { logger.debug("Selecting hint.", state.mode) const focused = modeState.focusedHint reset() focused.select() } import {addListener, attributeCaller} from './messaging' addListener('hinting_content', attributeCaller({ pushKey, selectFocusedHint, reset, hintPageSimple, hintPageYank, hintPageTextYank, hintPageAnchorYank, hintPageOpenInBackground, hintImage, hintFocus, hintRead, hintKill, hintSave, }))