mirror of
https://github.com/vale981/tridactyl
synced 2025-03-04 17:11:40 -05:00
Merge branch 'Koushien-completions'
Currently doesn't support "tab" and has lots of dead code. Only implemented for tabs right now.
This commit is contained in:
commit
0aa14bb754
12 changed files with 724 additions and 102 deletions
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -3053,6 +3053,11 @@
|
|||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
|
||||
"dev": true
|
||||
},
|
||||
"fuse.js": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.2.0.tgz",
|
||||
"integrity": "sha1-8ESOgGmFW/Kj5oPNwdMg5+KgfvQ="
|
||||
},
|
||||
"fx-runner": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/fx-runner/-/fx-runner-1.0.7.tgz",
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
"name": "tridactyl",
|
||||
"version": "0.1.0",
|
||||
"description": "Vimperator/Pentadactyl successor",
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"fuse.js": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^21.1.4",
|
||||
"@types/node": "^8.0.46",
|
||||
|
|
|
@ -21,5 +21,19 @@ export namespace onLine {
|
|||
}
|
||||
}
|
||||
|
||||
Messaging.addListener("commandline_background", Messaging.attributeCaller({recvExStr}))
|
||||
/** Helpers for completions */
|
||||
async function currentWindowTabs(): Promise<browser.tabs.Tab[]> {
|
||||
return await browser.tabs.query({currentWindow:true})
|
||||
}
|
||||
|
||||
async function allWindowTabs(): Promise<browser.tabs.Tab[]> {
|
||||
let allTabs: browser.tabs.Tab[] = []
|
||||
for (const window of await browser.windows.getAll()) {
|
||||
const tabs = await browser.tabs.query({windowId:window.id})
|
||||
allTabs = allTabs.concat(tabs)
|
||||
}
|
||||
return allTabs
|
||||
}
|
||||
|
||||
Messaging.addListener("commandline_background", Messaging.attributeCaller({currentWindowTabs, recvExStr}))
|
||||
}
|
||||
|
|
|
@ -1,21 +1,32 @@
|
|||
/** Script used in the commandline iframe. Communicates with background. */
|
||||
|
||||
import "./lib/html-tagged-template"
|
||||
|
||||
import * as Completions from './completions'
|
||||
import * as Messaging from './messaging'
|
||||
import * as SELF from './commandline_frame'
|
||||
import './number.clamp'
|
||||
import state from './state'
|
||||
|
||||
let completionsrc: Completions.CompletionSource = undefined
|
||||
let completions = window.document.getElementById("completions") as HTMLElement
|
||||
let clInput = window.document.getElementById("tridactyl-input") as HTMLInputElement
|
||||
|
||||
/* This is to handle Escape key which, while the cmdline is focused,
|
||||
* ends up firing both keydown and input listeners. In the worst case
|
||||
* hides the cmdline, shows and refocuses it and replaces its text
|
||||
* which could be the prefix to generate a completion.
|
||||
* tl;dr TODO: delete this and better resolve race condition
|
||||
*/
|
||||
let isVisible = false
|
||||
function resizeArea() { if (isVisible) sendExstr("showcmdline") }
|
||||
|
||||
export let focus = () => clInput.focus()
|
||||
|
||||
async function sendExstr(exstr) {
|
||||
Messaging.message("commandline_background", "recvExStr", [exstr])
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Process the commandline on enter. */
|
||||
clInput.addEventListener("keydown", function (keyevent) {
|
||||
switch (keyevent.key) {
|
||||
|
@ -63,6 +74,27 @@ clInput.addEventListener("keydown", function (keyevent) {
|
|||
}
|
||||
})
|
||||
|
||||
clInput.addEventListener("input", async () => {
|
||||
// TODO: Handle this in parser
|
||||
if (clInput.value.startsWith("buffer ") || clInput.value.startsWith("tabclose ") ||
|
||||
clInput.value.startsWith("tabmove ")) {
|
||||
const tabs: browser.tabs.Tab[] = await Messaging.message("commandline_background", "currentWindowTabs")
|
||||
completionsrc = Completions.BufferCompletionSource.fromTabs(tabs)
|
||||
completionsrc = await completionsrc.filter(clInput.value)
|
||||
completions.innerHTML = ""
|
||||
completions.appendChild(completionsrc.node)
|
||||
resizeArea()
|
||||
}
|
||||
else if (clInput.value.startsWith("bufferall ")) {
|
||||
// TODO
|
||||
}
|
||||
else if (completionsrc) {
|
||||
completionsrc = undefined
|
||||
completions.innerHTML = ""
|
||||
resizeArea()
|
||||
}
|
||||
})
|
||||
|
||||
let cmdline_history_position = 0
|
||||
let cmdline_history_current = ""
|
||||
|
||||
|
@ -71,9 +103,11 @@ function hide_and_clear(){
|
|||
* keydown event, presumably due to Firefox's internal handler for
|
||||
* Escape. So clear clInput just after :)
|
||||
*/
|
||||
completionsrc = undefined
|
||||
completions.innerHTML = ""
|
||||
setTimeout(()=>{clInput.value = ""}, 0)
|
||||
sendExstr("hidecmdline")
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
function tabcomplete(){
|
||||
|
@ -86,7 +120,7 @@ function tabcomplete(){
|
|||
function history(n){
|
||||
completions.innerHTML = ""
|
||||
if (cmdline_history_position == 0){
|
||||
cmdline_history_current = clInput.value
|
||||
cmdline_history_current = clInput.value
|
||||
}
|
||||
let wrapped_ind = state.cmdHistory.length + n - cmdline_history_position
|
||||
wrapped_ind = wrapped_ind.clamp(0, state.cmdHistory.length)
|
||||
|
@ -110,7 +144,7 @@ function process() {
|
|||
state.cmdHistory = state.cmdHistory.concat([clInput.value])
|
||||
}
|
||||
console.log(state.cmdHistory)
|
||||
|
||||
completionsrc = undefined
|
||||
completions.innerHTML = ""
|
||||
clInput.value = ""
|
||||
cmdline_history_position = 0
|
||||
|
@ -123,10 +157,8 @@ export function fillcmdline(newcommand?: string, trailspace = true){
|
|||
}
|
||||
// Focus is lost for some reason.
|
||||
focus()
|
||||
}
|
||||
|
||||
export function changecompletions(newcompletions: string) {
|
||||
completions.innerHTML = newcompletions
|
||||
isVisible = true
|
||||
clInput.dispatchEvent(new Event('input')) // dirty hack for completions
|
||||
}
|
||||
|
||||
function applyWithTmpTextArea(fn) {
|
||||
|
|
188
src/completions.ts
Normal file
188
src/completions.ts
Normal file
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
|
||||
Have an array of all completion sources. Completion sources display nothing if the filter doesn't match for them.
|
||||
|
||||
On each input event, call updateCompletions on the array. That will mutate the array and update the display as required.
|
||||
|
||||
How to handle cached e.g. buffer information going out of date?
|
||||
|
||||
*/
|
||||
|
||||
|
||||
import * as Fuse from 'fuse.js'
|
||||
import {enumerate} from './itertools'
|
||||
import {toNumber} from './convert'
|
||||
|
||||
const DEFAULT_FAVICON = browser.extension.getURL("static/defaultFavicon.svg")
|
||||
|
||||
// {{{ INTERFACES
|
||||
|
||||
interface CompletionOption {
|
||||
// What to fill into cmdline
|
||||
value: string
|
||||
|
||||
// Highlight and blur element,
|
||||
blur(): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export abstract class CompletionSource {
|
||||
private obsolete = false
|
||||
|
||||
readonly options = new Array<CompletionOption>()
|
||||
|
||||
public node: HTMLElement
|
||||
|
||||
// Called by updateCompletions on the child that succeeds its parent
|
||||
abstract activate(): void
|
||||
// this.node now belongs to you, update it or something :)
|
||||
// Example: Mutate node or call replaceChild on its parent
|
||||
|
||||
abstract async filter(exstr): Promise<CompletionSource>
|
||||
// <Do some async work that doesn't mutate any non-local vars>
|
||||
// Make a new CompletionOptions and return it
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
||||
// {{{ IMPLEMENTATIONS
|
||||
|
||||
class BufferCompletionOption implements CompletionOption {
|
||||
// keep a reference to our markup so we can highlight it on focus().
|
||||
html: HTMLElement
|
||||
|
||||
// For fuzzy matching
|
||||
matchStrings: string[] = []
|
||||
|
||||
constructor(public value: string, tab: browser.tabs.Tab, isAlternative = false) {
|
||||
// Two character buffer properties prefix
|
||||
let pre = ""
|
||||
if (tab.active) pre += "%"
|
||||
else if (isAlternative) pre += "#"
|
||||
if (tab.pinned) { pre += "@" }
|
||||
this.matchStrings.push(pre) // before pad so we don't match whitespace
|
||||
pre = pre.padEnd(2)
|
||||
this.matchStrings.push(String(tab.index + 1), tab.title, tab.url)
|
||||
const favIconUrl = tab.favIconUrl ? tab.favIconUrl : DEFAULT_FAVICON
|
||||
this.html = html`<div class="BufferCompletionOption option">
|
||||
<span>${pre}</span>
|
||||
<img src=${favIconUrl} />
|
||||
<span>${tab.index + 1}: ${tab.title}</span>
|
||||
<a class="url" href=${tab.url}>${tab.url}</a>
|
||||
</div>`
|
||||
}
|
||||
|
||||
blur() { this.html.classList.remove("focused") }
|
||||
focus() { this.html.classList.add("focused"); this.show() }
|
||||
hide() { this.html.classList.add("hidden"); this.blur() }
|
||||
show() { this.html.classList.remove("hidden") }
|
||||
}
|
||||
|
||||
export class BufferCompletionSource extends CompletionSource {
|
||||
private fuse: Fuse
|
||||
public prefixes = [ "buffer " ]
|
||||
|
||||
constructor(
|
||||
readonly options: BufferCompletionOption[],
|
||||
public node: HTMLElement,
|
||||
) {
|
||||
super()
|
||||
const fuseOptions = {
|
||||
keys: ["matchStrings"],
|
||||
shouldSort: true,
|
||||
id: "index",
|
||||
}
|
||||
|
||||
// Can't sort the real options array because Fuse loses class information.
|
||||
const searchThis = options.map((elem, index) => {return {index, matchStrings: elem.matchStrings}})
|
||||
this.fuse = new Fuse(searchThis, fuseOptions)
|
||||
}
|
||||
|
||||
static fromTabs(tabs: browser.tabs.Tab[]) {
|
||||
const node = html`<div class="BufferCompletionSource">
|
||||
<div class="sectionHeader">Buffers</div>`
|
||||
|
||||
// Get alternative tab, defined as last accessed tab.
|
||||
const alt = tabs.sort((a, b) => { return a.lastAccessed < b.lastAccessed ? 1 : -1 })[1]
|
||||
tabs.sort((a, b) => { return a.index < b.index ? -1 : 1 })
|
||||
|
||||
const options: BufferCompletionOption[] = []
|
||||
|
||||
for (const tab of tabs) {
|
||||
options.push(new BufferCompletionOption(
|
||||
(tab.index + 1).toString(),
|
||||
tab,
|
||||
tab === alt)
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of options) {
|
||||
node.appendChild(option.html)
|
||||
}
|
||||
|
||||
return new BufferCompletionSource(options, node)
|
||||
}
|
||||
|
||||
activate() {
|
||||
// TODO... this bit of the interface isn't super clear to me yet.
|
||||
}
|
||||
|
||||
async filter(exstr: string) {
|
||||
// Remove the `${prefix} ` bit.
|
||||
const query = exstr.slice(exstr.indexOf(' ') + 1)
|
||||
|
||||
if (query) {
|
||||
let matches = this.fuse.search(query).map(toNumber) as number[]
|
||||
|
||||
for (const [index, option] of enumerate(this.options)) {
|
||||
if (! matches.includes(index)) option.hide()
|
||||
else option.show()
|
||||
}
|
||||
|
||||
if (matches.length) this.options[matches[0]].focus()
|
||||
} else {
|
||||
for (const option of this.options) {
|
||||
option.show()
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
||||
// {{{ UNUSED: MANAGING ASYNC CHANGES
|
||||
|
||||
/* If first to modify completions, update it. */
|
||||
async function commitIfCurrent(epochref: any, asyncFunc: Function, commitFunc: Function, ...args: any[]): Promise<any> {
|
||||
// I *think* sync stuff in here is guaranteed to happen immediately after
|
||||
// being called, up to the first await, despite this being an async
|
||||
// function. But I don't know. Should check.
|
||||
const epoch = epochref
|
||||
const res = await asyncFunc(...args)
|
||||
if (epoch === epochref) return commitFunc(res)
|
||||
else console.error(new Error("Update failed: epoch out of date!"))
|
||||
}
|
||||
|
||||
/* Indicate changes to completions we would like. */
|
||||
function updateCompletions(filter: string, sources: CompletionSource[]) {
|
||||
for (let [index, source] of enumerate(sources)) {
|
||||
// Tell each compOpt to filter, and if they finish fast enough they:
|
||||
// 0. Leave a note for any siblings that they got here first
|
||||
// 1. Take over their parent's slot in compOpts
|
||||
// 2. Update their display
|
||||
commitIfCurrent(
|
||||
source.obsolete, // Flag/epoch
|
||||
source.filter, // asyncFunc
|
||||
(childSource) => { // commitFunc
|
||||
source.obsolete = true
|
||||
sources[index] = childSource
|
||||
childSource.activate()
|
||||
},
|
||||
filter // argument to asyncFunc
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// }}}
|
|
@ -3,6 +3,7 @@
|
|||
// Be careful: typescript elides imports that appear not to be used if they're
|
||||
// assigned to a name. If you want an import just for its side effects, make
|
||||
// sure you import it like this:
|
||||
import "./lib/html-tagged-template"
|
||||
/* import "./keydown_content" */
|
||||
/* import "./commandline_content" */
|
||||
/* import "./excmds_content" */
|
||||
|
|
|
@ -580,17 +580,14 @@ export async function clipboard(excmd: "open"|"yank"|"tabopen" = "open", ...toYa
|
|||
|
||||
// {{{ Buffer/completion stuff
|
||||
// TODO: Move autocompletions out of excmds.
|
||||
|
||||
/** @hidden */
|
||||
//#background_helper
|
||||
const DEFAULT_FAVICON = browser.extension.getURL("static/defaultFavicon.svg")
|
||||
|
||||
/** Soon to be deprecated way of showing buffer completions */
|
||||
/** Ported from Vimperator. */
|
||||
//#background
|
||||
export async function openbuffer() {
|
||||
export async function tabs() {
|
||||
fillcmdline("buffer")
|
||||
messageActiveTab("commandline_frame", "changecompletions", [await l(listTabs())])
|
||||
showcmdline()
|
||||
}
|
||||
//#background
|
||||
export async function buffers() {
|
||||
tabs()
|
||||
}
|
||||
|
||||
/** Change active tab */
|
||||
|
@ -618,66 +615,10 @@ export async function buffer(n?: number | string) {
|
|||
}
|
||||
}
|
||||
|
||||
/** List of tabs in window and the last active tab. */
|
||||
/** @hidden */
|
||||
//#background_helper
|
||||
async function getTabs() {
|
||||
const tabs = await browser.tabs.query({currentWindow: true})
|
||||
const lastActive = tabs.sort((a, b) => {
|
||||
return a.lastAccessed < b.lastAccessed ? 1 : -1
|
||||
})[1]
|
||||
tabs.sort((a, b) => {
|
||||
return a.index < b.index ? -1 : 1
|
||||
})
|
||||
console.log(tabs)
|
||||
return [tabs, lastActive]
|
||||
}
|
||||
|
||||
/** innerHTML for a single Tab's representation in autocompletion */
|
||||
/** @hidden */
|
||||
//#background_helper
|
||||
function formatTab(tab: browser.tabs.Tab, prev?: boolean) {
|
||||
// This, like all this completion logic, needs to move.
|
||||
const tabline = window.document.createElement('div')
|
||||
tabline.className = "tabline"
|
||||
|
||||
const prefix = window.document.createElement('span')
|
||||
if (tab.active) prefix.textContent += "%"
|
||||
else if (prev) prefix.textContent += "#"
|
||||
if (tab.pinned) prefix.textContent += "@"
|
||||
prefix.textContent = prefix.textContent.padEnd(2)
|
||||
tabline.appendChild(prefix)
|
||||
|
||||
// TODO: Dynamically set favicon dimensions. Should be able to use em.
|
||||
const favicon = window.document.createElement('img')
|
||||
favicon.src = tab.favIconUrl ? tab.favIconUrl : DEFAULT_FAVICON
|
||||
tabline.appendChild(favicon)
|
||||
|
||||
const titlespan = window.document.createElement('span')
|
||||
titlespan.textContent=`${tab.index + 1}: ${tab.title}`
|
||||
tabline.appendChild(titlespan)
|
||||
|
||||
const url = window.document.createElement('a')
|
||||
url.className = 'url'
|
||||
url.href = tab.url
|
||||
url.text = tab.url
|
||||
url.target = '_blank'
|
||||
tabline.appendChild(url)
|
||||
|
||||
console.log(tabline)
|
||||
return tabline.outerHTML
|
||||
}
|
||||
|
||||
/** innerHTML for tab autocompletion div */
|
||||
/** @hidden */
|
||||
//#background_helper
|
||||
async function listTabs() {
|
||||
let buffers: string = "",
|
||||
[tabs, lastActive] = await getTabs()
|
||||
for (let tab of tabs as Array<browser.tabs.Tab>) {
|
||||
buffers += tab === lastActive ? formatTab(tab, true) : formatTab(tab)
|
||||
}
|
||||
return buffers
|
||||
/** Set tab with index of n belonging to window with id of m to active */
|
||||
//#background
|
||||
export async function bufferall(m?: number, n?: number) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
@ -776,7 +717,6 @@ export function hint(option?: "-b") {
|
|||
else hinting.hintPageSimple()
|
||||
}
|
||||
|
||||
|
||||
// }}}
|
||||
|
||||
// {{{ GOBBLE mode
|
||||
|
|
408
src/lib/html-tagged-template.js
Normal file
408
src/lib/html-tagged-template.js
Normal file
|
@ -0,0 +1,408 @@
|
|||
(function(window) {
|
||||
"use strict";
|
||||
|
||||
// test for es6 support of needed functionality
|
||||
try {
|
||||
// spread operator and template strings support
|
||||
(function testSpreadOpAndTemplate() {
|
||||
const tag = function tag(strings, ...values) {return;};
|
||||
tag`test`;
|
||||
})();
|
||||
|
||||
|
||||
// template tag and Array.from support
|
||||
if (!('content' in document.createElement('template') && 'from' in Array)) {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// missing support;
|
||||
console.log('Your browser does not support the needed functionality to use the html tagged template');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.html === 'undefined') {
|
||||
|
||||
// --------------------------------------------------
|
||||
// constants
|
||||
// --------------------------------------------------
|
||||
|
||||
const SUBSTITUTION_INDEX = 'substitutionindex:'; // tag names are always all lowercase
|
||||
const SUBSTITUTION_REGEX = new RegExp(SUBSTITUTION_INDEX + '([0-9]+):', 'g');
|
||||
|
||||
// rejection string is used to replace xss attacks that cannot be escaped either
|
||||
// because the escaped string is still executable
|
||||
// (e.g. setTimeout(/* escaped string */)) or because it produces invalid results
|
||||
// (e.g. <h${xss}> where xss='><script>alert(1337)</script')
|
||||
// @see https://developers.google.com/closure/templates/docs/security#in_tags_and_attrs
|
||||
const REJECTION_STRING = 'zXssPreventedz';
|
||||
|
||||
// which characters should be encoded in which contexts
|
||||
const ENCODINGS = {
|
||||
attribute: {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>'
|
||||
},
|
||||
uri: {
|
||||
'&': '&'
|
||||
}
|
||||
};
|
||||
|
||||
// which attributes are DOM Level 0 events
|
||||
// taken from https://en.wikipedia.org/wiki/DOM_events#DOM_Level_0
|
||||
const DOM_EVENTS = ["onclick", "ondblclick", "onmousedown", "onmouseup", "onmouseover", "onmousemove", "onmouseout", "ondragstart", "ondrag", "ondragenter", "ondragleave", "ondragover", "ondrop", "ondragend", "onkeydown", "onkeypress", "onkeyup", "onload", "onunload", "onabort", "onerror", "onresize", "onscroll", "onselect", "onchange", "onsubmit", "onreset", "onfocus", "onblur", "onpointerdown", "onpointerup", "onpointercancel", "onpointermove", "onpointerover", "onpointerout", "onpointerenter", "onpointerleave", "ongotpointercapture", "onlostpointercapture", "oncut", "oncopy", "onpaste", "onbeforecut", "onbeforecopy", "onbeforepaste", "onafterupdate", "onbeforeupdate", "oncellchange", "ondataavailable", "ondatasetchanged", "ondatasetcomplete", "onerrorupdate", "onrowenter", "onrowexit", "onrowsdelete", "onrowinserted", "oncontextmenu", "ondrag", "ondragstart", "ondragenter", "ondragover", "ondragleave", "ondragend", "ondrop", "onselectstart", "help", "onbeforeunload", "onstop", "beforeeditfocus", "onstart", "onfinish", "onbounce", "onbeforeprint", "onafterprint", "onpropertychange", "onfilterchange", "onreadystatechange", "onlosecapture", "DOMMouseScroll", "ondragdrop", "ondragenter", "ondragexit", "ondraggesture", "ondragover", "onclose", "oncommand", "oninput", "DOMMenuItemActive", "DOMMenuItemInactive", "oncontextmenu", "onoverflow", "onoverflowchanged", "onunderflow", "onpopuphidden", "onpopuphiding", "onpopupshowing", "onpopupshown", "onbroadcast", "oncommandupdate"];
|
||||
|
||||
// which attributes take URIs
|
||||
// taken from https://www.w3.org/TR/html4/index/attributes.html
|
||||
const URI_ATTRIBUTES = ["action", "background", "cite", "classid", "codebase", "data", "href", "longdesc", "profile", "src", "usemap"];
|
||||
|
||||
const ENCODINGS_REGEX = {
|
||||
attribute: new RegExp('[' + Object.keys(ENCODINGS.attribute).join('') + ']', 'g'),
|
||||
uri: new RegExp('[' + Object.keys(ENCODINGS.uri).join('') + ']', 'g')
|
||||
};
|
||||
|
||||
// find all attributes after the first whitespace (which would follow the tag
|
||||
// name. Only used when the DOM has been clobbered to still parse attributes
|
||||
const ATTRIBUTE_PARSER_REGEX = /\s([^">=\s]+)(?:="[^"]+")?/g;
|
||||
|
||||
// test if a javascript substitution is wrapped with quotes
|
||||
const WRAPPED_WITH_QUOTES_REGEX = /^('|")[\s\S]*\1$/;
|
||||
|
||||
// allow custom attribute names that start or end with url or ui to do uri escaping
|
||||
// @see https://developers.google.com/closure/templates/docs/security#in_urls
|
||||
const CUSTOM_URI_ATTRIBUTES_REGEX = /\bur[il]|ur[il]s?$/i;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --------------------------------------------------
|
||||
// private functions
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Escape HTML entities in an attribute.
|
||||
* @private
|
||||
*
|
||||
* @param {string} str - String to escape.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeAttributeHTMLEntities(str) {
|
||||
return str.replace(ENCODINGS_REGEX.attribute, function(match) {
|
||||
return ENCODINGS.attribute[match];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape entities in a URI.
|
||||
* @private
|
||||
*
|
||||
* @param {string} str - URI to escape.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeURIEntities(str) {
|
||||
return str.replace(ENCODINGS_REGEX.uri, function(match) {
|
||||
return ENCODINGS.uri[match];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --------------------------------------------------
|
||||
// html tagged template function
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Safely convert a DOM string into DOM nodes using by using E4H and contextual
|
||||
* auto-escaping techniques to prevent xss attacks.
|
||||
*
|
||||
* @param {string[]} strings - Safe string literals.
|
||||
* @param {*} values - Unsafe substitution expressions.
|
||||
*
|
||||
* @returns {HTMLElement|DocumentFragment}
|
||||
*/
|
||||
window.html = function(strings, ...values) {
|
||||
// break early if called with empty content
|
||||
if (!strings[0] && values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a string with substitution placeholders with its substitution values.
|
||||
* @private
|
||||
*
|
||||
* @param {string} match - Matched substitution placeholder.
|
||||
* @param {string} index - Substitution placeholder index.
|
||||
*/
|
||||
function replaceSubstitution(match, index) {
|
||||
return values[parseInt(index, 10)];
|
||||
}
|
||||
|
||||
// insert placeholders into the generated string so we can run it through the
|
||||
// HTML parser without any malicious content.
|
||||
// (this particular placeholder will even work when used to create a DOM element)
|
||||
let str = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
str += SUBSTITUTION_INDEX + i + ':' + strings[i+1];
|
||||
}
|
||||
|
||||
// template tags allow any HTML (even <tr> elements out of context)
|
||||
// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template
|
||||
let template = document.createElement('template');
|
||||
template.innerHTML = str;
|
||||
|
||||
// find all substitution values and safely encode them using DOM APIs and
|
||||
// contextual auto-escaping
|
||||
let walker = document.createNodeIterator(template.content, NodeFilter.SHOW_ALL);
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
let tag = null;
|
||||
let attributesToRemove = [];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --------------------------------------------------
|
||||
// node name substitution
|
||||
// --------------------------------------------------
|
||||
|
||||
let nodeName = node.nodeName.toLowerCase();
|
||||
if (nodeName.indexOf(SUBSTITUTION_INDEX) !== -1) {
|
||||
nodeName = nodeName.replace(SUBSTITUTION_REGEX, replaceSubstitution);
|
||||
|
||||
// createElement() should not need to be escaped to prevent XSS?
|
||||
|
||||
// this will throw an error if the tag name is invalid (e.g. xss tried
|
||||
// to escape out of the tag using '><script>alert(1337)</script><')
|
||||
// instead of replacing the tag name we'll just let the error be thrown
|
||||
tag = document.createElement(nodeName);
|
||||
|
||||
// mark that this node needs to be cleaned up later with the newly
|
||||
// created node
|
||||
node._replacedWith = tag;
|
||||
|
||||
// use insertBefore() instead of replaceChild() so that the node Iterator
|
||||
// doesn't think the new tag should be the next node
|
||||
node.parentNode.insertBefore(tag, node);
|
||||
}
|
||||
|
||||
// special case for script tags:
|
||||
// using innerHTML with a string that contains a script tag causes the script
|
||||
// tag to not be executed when added to the DOM. We'll need to create a script
|
||||
// tag and append its contents which will make it execute correctly.
|
||||
// @see http://stackoverflow.com/questions/1197575/can-scripts-be-inserted-with-innerhtml
|
||||
else if (node.nodeName === 'SCRIPT') {
|
||||
let script = document.createElement('script');
|
||||
tag = script;
|
||||
|
||||
node._replacedWith = script;
|
||||
node.parentNode.insertBefore(script, node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --------------------------------------------------
|
||||
// attribute substitution
|
||||
// --------------------------------------------------
|
||||
|
||||
let attributes;
|
||||
if (node.attributes) {
|
||||
|
||||
// if the attributes property is not of type NamedNodeMap then the DOM
|
||||
// has been clobbered. E.g. <form><input name="attributes"></form>.
|
||||
// We'll manually build up an array of objects that mimic the Attr
|
||||
// object so the loop will still work as expected.
|
||||
if ( !(node.attributes instanceof NamedNodeMap) ) {
|
||||
|
||||
// first clone the node so we can isolate it from any children
|
||||
let temp = node.cloneNode();
|
||||
|
||||
// parse the node string for all attributes
|
||||
let attributeMatches = temp.outerHTML.match(ATTRIBUTE_PARSER_REGEX);
|
||||
|
||||
// get all attribute names and their value
|
||||
attributes = [];
|
||||
for (let i = 0; i < attributeMatches.length; i++) {
|
||||
let attributeName = attributeMatches[i].trim().split('=')[0];
|
||||
let attributeValue = node.getAttribute(attributeName);
|
||||
|
||||
attributes.push({
|
||||
name: attributeName,
|
||||
value: attributeValue
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Windows 10 Firefox 44 will shift the attributes NamedNodeMap and
|
||||
// push the attribute to the end when using setAttribute(). We'll have
|
||||
// to clone the NamedNodeMap so the order isn't changed for setAttribute()
|
||||
attributes = Array.from(node.attributes);
|
||||
}
|
||||
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
let attribute = attributes[i];
|
||||
let name = attribute.name;
|
||||
let value = attribute.value;
|
||||
let hasSubstitution = false;
|
||||
|
||||
// name has substitution
|
||||
if (name.indexOf(SUBSTITUTION_INDEX) !== -1) {
|
||||
name = name.replace(SUBSTITUTION_REGEX, replaceSubstitution);
|
||||
|
||||
// ensure substitution was with a non-empty string
|
||||
if (name && typeof name === 'string') {
|
||||
hasSubstitution = true;
|
||||
}
|
||||
|
||||
// remove old attribute
|
||||
attributesToRemove.push(attribute.name);
|
||||
}
|
||||
|
||||
// value has substitution - only check if name exists (only happens
|
||||
// when name is a substitution with an empty value)
|
||||
if (name && value.indexOf(SUBSTITUTION_INDEX) !== -1) {
|
||||
hasSubstitution = true;
|
||||
|
||||
// if an uri attribute has been rejected
|
||||
let isRejected = false;
|
||||
|
||||
value = value.replace(SUBSTITUTION_REGEX, function(match, index, offset) {
|
||||
if (isRejected) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let substitutionValue = values[parseInt(index, 10)];
|
||||
|
||||
// contextual auto-escaping:
|
||||
// if attribute is a DOM Level 0 event then we need to ensure it
|
||||
// is quoted
|
||||
if (DOM_EVENTS.indexOf(name) !== -1 &&
|
||||
typeof substitutionValue === 'string' &&
|
||||
!WRAPPED_WITH_QUOTES_REGEX.test(substitutionValue) ) {
|
||||
substitutionValue = '"' + substitutionValue + '"';
|
||||
}
|
||||
|
||||
// contextual auto-escaping:
|
||||
// if the attribute is a uri attribute then we need to uri encode it and
|
||||
// remove bad protocols
|
||||
else if (URI_ATTRIBUTES.indexOf(name) !== -1 ||
|
||||
CUSTOM_URI_ATTRIBUTES_REGEX.test(name)) {
|
||||
|
||||
// percent encode if the value is inside of a query parameter
|
||||
let queryParamIndex = value.indexOf('=');
|
||||
if (queryParamIndex !== -1 && offset > queryParamIndex) {
|
||||
substitutionValue = encodeURIComponent(substitutionValue);
|
||||
}
|
||||
|
||||
// entity encode if value is part of the URL
|
||||
else {
|
||||
substitutionValue = encodeURI( encodeURIEntities(substitutionValue) );
|
||||
|
||||
// only allow the : when used after http or https otherwise reject
|
||||
// the entire url (will not allow any 'javascript:' or filter
|
||||
// evasion techniques)
|
||||
if (offset === 0 && substitutionValue.indexOf(':') !== -1) {
|
||||
let protocol = substitutionValue.substring(0, 5);
|
||||
if (protocol.indexOf('http') === -1) {
|
||||
isRejected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contextual auto-escaping:
|
||||
// HTML encode attribute value if it is not a URL or URI to prevent
|
||||
// DOM Level 0 event handlers from executing xss code
|
||||
else if (typeof substitutionValue === 'string') {
|
||||
substitutionValue = encodeAttributeHTMLEntities(substitutionValue);
|
||||
}
|
||||
|
||||
return substitutionValue;
|
||||
});
|
||||
|
||||
if (isRejected) {
|
||||
value = '#' + REJECTION_STRING;
|
||||
}
|
||||
}
|
||||
|
||||
// add the attribute to the new tag or replace it on the current node
|
||||
// setAttribute() does not need to be escaped to prevent XSS since it does
|
||||
// all of that for us
|
||||
// @see https://www.mediawiki.org/wiki/DOM-based_XSS
|
||||
if (tag || hasSubstitution) {
|
||||
let el = (tag || node);
|
||||
|
||||
// optional attribute
|
||||
if (name.substr(-1) === '?') {
|
||||
el.removeAttribute(name);
|
||||
|
||||
if (value === 'true') {
|
||||
name = name.slice(0, -1);
|
||||
el.setAttribute(name, '');
|
||||
}
|
||||
}
|
||||
else {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove placeholder attributes outside of the attribute loop since it
|
||||
// will modify the attributes NamedNodeMap indices.
|
||||
// @see https://github.com/straker/html-tagged-template/issues/13
|
||||
attributesToRemove.forEach(function(attribute) {
|
||||
node.removeAttribute(attribute);
|
||||
});
|
||||
|
||||
// append the current node to a replaced parent
|
||||
let parentNode;
|
||||
if (node.parentNode && node.parentNode._replacedWith) {
|
||||
parentNode = node.parentNode;
|
||||
node.parentNode._replacedWith.appendChild(node);
|
||||
}
|
||||
|
||||
// remove the old node from the DOM
|
||||
if ((node._replacedWith && node.childNodes.length === 0) ||
|
||||
(parentNode && parentNode.childNodes.length === 0) ){
|
||||
(parentNode || node).remove();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --------------------------------------------------
|
||||
// text content substitution
|
||||
// --------------------------------------------------
|
||||
|
||||
if (node.nodeType === 3 && node.nodeValue.indexOf(SUBSTITUTION_INDEX) !== -1) {
|
||||
let nodeValue = node.nodeValue.replace(SUBSTITUTION_REGEX, replaceSubstitution);
|
||||
|
||||
// createTextNode() should not need to be escaped to prevent XSS?
|
||||
let text = document.createTextNode(nodeValue);
|
||||
|
||||
// since the parent node has already gone through the iterator, we can use
|
||||
// replaceChild() here
|
||||
node.parentNode.replaceChild(text, node);
|
||||
}
|
||||
}
|
||||
|
||||
// return the documentFragment for multiple nodes
|
||||
if (template.content.childNodes.length > 1) {
|
||||
return template.content;
|
||||
}
|
||||
|
||||
return template.content.firstChild;
|
||||
};
|
||||
}
|
||||
|
||||
})(window);
|
|
@ -44,7 +44,8 @@ export const DEFAULTNMAPS = {
|
|||
"S": "fillcmdline tabopen google",
|
||||
"M": "gobble 1 quickmark",
|
||||
"xx": "something",
|
||||
"b": "openbuffer",
|
||||
"B": "fillcmdline bufferall",
|
||||
"b": "fillcmdline buffer",
|
||||
"ZZ": "qall",
|
||||
"f": "hint",
|
||||
"F": "hint -b",
|
||||
|
|
|
@ -7,7 +7,8 @@ input {
|
|||
width: 100%;
|
||||
padding: 0;
|
||||
font-family: "monospace", "Courier New";
|
||||
font-size: 10pt;
|
||||
font-size: 9pt;
|
||||
line-height: 1.5;
|
||||
color: black;
|
||||
border: unset;
|
||||
/* we currently have a border from the completions */
|
||||
|
@ -15,35 +16,59 @@ input {
|
|||
padding-left: 0.5ex;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
|
||||
.focused {
|
||||
background: darkgrey;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* COMPLETIONS */
|
||||
|
||||
#completions {
|
||||
--option-height: 1.4em;
|
||||
background: white;
|
||||
color: black;
|
||||
display: inline-block;
|
||||
font-size: 10pt;
|
||||
height: auto;
|
||||
/* line-height: 1.5em; */
|
||||
font-family: "monospace", "Courier New";
|
||||
max-height: calc(10 * var(--option-height));
|
||||
font-family: "monospace";
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
width: 100%;
|
||||
z-index: 9998;
|
||||
border-top: 1px solid lightgray;
|
||||
border-top: 0.5px solid grey;
|
||||
}
|
||||
|
||||
.url, a.url, a.url:link, a.url:visited {
|
||||
background: white;
|
||||
color: #1f9947;
|
||||
#completions img {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
#completions .sectionHeader {
|
||||
background: linear-gradient(lightgrey, #ddd, lightgrey);
|
||||
font-weight: bold;
|
||||
border-bottom: 0.5px solid grey;
|
||||
padding-left: 0.5ex;
|
||||
}
|
||||
|
||||
#completions .sectionHeader, #completions .option {
|
||||
height: --option-height;
|
||||
line-height: --option-height;
|
||||
}
|
||||
|
||||
/* #completions, but I can't be bothered to write it a bunch of times */
|
||||
.url, a.url, a.url:link, a.url:visited {
|
||||
color: #1f9947;
|
||||
left: 50%;
|
||||
overflow: hidden;
|
||||
padding: 0 2px;
|
||||
margin-left: 10px;
|
||||
position: absolute;
|
||||
text-decoration: none;
|
||||
/* Without this the last URL flows over onto a second line if it's long enough */
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Hide the URLs if the screen is small */
|
||||
|
@ -58,11 +83,7 @@ a.url:hover {
|
|||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
div.tabline span {
|
||||
.BufferCompletionOption span {
|
||||
white-space: pre;
|
||||
margin: 0 .5em;
|
||||
}
|
||||
|
||||
div.tabline img {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
8
src/tridactyl.d.ts
vendored
8
src/tridactyl.d.ts
vendored
|
@ -16,6 +16,11 @@ interface Window {
|
|||
scrollByPages(n: number): void
|
||||
}
|
||||
|
||||
// Fix typescript bugs
|
||||
interface StringConstructor {
|
||||
toLowerCase(): string;
|
||||
}
|
||||
|
||||
// Web extension types not in web-ext-types yet
|
||||
declare namespace browser.find {
|
||||
function find(query, object): any
|
||||
|
@ -25,3 +30,6 @@ declare namespace browser.find {
|
|||
declare namespace browser.tabs {
|
||||
function setZoom(number): any
|
||||
}
|
||||
|
||||
// html-tagged-template.js
|
||||
declare function html(strings: TemplateStringsArray, ...values: any[]): HTMLElement
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Node",
|
||||
"noImplicitAny": false,
|
||||
"noEmitOnError": true,
|
||||
"outDir": "build/tsc-out",
|
||||
"sourceMap": true,
|
||||
"target": "ES2017",
|
||||
"lib": ["ES2017","dom"],
|
||||
"typeRoots": ["node_modules/@types", "node_modules/web-ext-types/"]
|
||||
"typeRoots": ["node_modules/@types", "node_modules/web-ext-types/"],
|
||||
"alwaysStrict": true
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
|
|
Loading…
Add table
Reference in a new issue