add base: browser window, hints, history, cookies, adblock
This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
||||
#include "hints.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto kChars = "asdfghjkl";
|
||||
|
||||
// Installs window.__attHints and shows the labels. "@CHARS@" is substituted
|
||||
// with kChars before running.
|
||||
constexpr auto kShowScript = R"JS(
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__attHints)
|
||||
window.__attHints.clear();
|
||||
|
||||
const CHARS = '@CHARS@';
|
||||
const SELECTOR = [
|
||||
'a[href]', 'area[href]', 'button', 'input:not([type=hidden])', 'select',
|
||||
'textarea', 'summary', 'label', '[onclick]', '[contenteditable=""]',
|
||||
'[contenteditable=true]', '[role=link]', '[role=button]',
|
||||
'[role=checkbox]', '[role=radio]', '[role=tab]', '[role=menuitem]',
|
||||
'[role=option]', '[role=switch]', '[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
const TEXT_INPUTS = new Set(['', 'text', 'search', 'email', 'url', 'tel',
|
||||
'password', 'number', 'date', 'datetime-local',
|
||||
'month', 'time', 'week']);
|
||||
|
||||
function visibleRect(el) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.visibility !== 'visible' || parseFloat(style.opacity) === 0)
|
||||
return null;
|
||||
// getClientRects rather than getBoundingClientRect so a link wrapped
|
||||
// over two lines gets its label on the first line, not in the gap.
|
||||
for (const r of el.getClientRects()) {
|
||||
if (r.width < 1 || r.height < 1)
|
||||
continue;
|
||||
if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth)
|
||||
continue;
|
||||
return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEditable(el) {
|
||||
if (el.isContentEditable || el.tagName === 'TEXTAREA')
|
||||
return true;
|
||||
return el.tagName === 'INPUT' && TEXT_INPUTS.has((el.getAttribute('type') || '').toLowerCase());
|
||||
}
|
||||
|
||||
// All labels have the same length, so none is a prefix of another.
|
||||
function makeLabels(count) {
|
||||
const n = CHARS.length;
|
||||
let len = 1;
|
||||
while (Math.pow(n, len) < count)
|
||||
len++;
|
||||
const labels = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let label = '';
|
||||
for (let j = 0, k = i; j < len; j++, k = Math.floor(k / n))
|
||||
label = CHARS[k % n] + label;
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
const targets = [];
|
||||
const seen = new Set();
|
||||
for (const el of document.querySelectorAll(SELECTOR)) {
|
||||
if (el.disabled || seen.has(el))
|
||||
continue;
|
||||
const rect = visibleRect(el);
|
||||
if (!rect)
|
||||
continue;
|
||||
// A <label> that wraps its input would otherwise get two hints.
|
||||
if (el.tagName === 'LABEL' && el.control && el.contains(el.control) && visibleRect(el.control))
|
||||
continue;
|
||||
seen.add(el);
|
||||
targets.push({ el, rect });
|
||||
}
|
||||
|
||||
// A shadow root keeps the page's stylesheets off the labels.
|
||||
const host = document.createElement('att-hints');
|
||||
host.style.cssText = 'all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none;';
|
||||
const root = host.attachShadow({ mode: 'closed' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
span.hint {
|
||||
position: absolute;
|
||||
padding: 0 2px;
|
||||
background: linear-gradient(to bottom, #fff785, #ffc542);
|
||||
border: 1px solid #c38a22;
|
||||
border-radius: 3px;
|
||||
color: #000;
|
||||
font: bold 12px monospace;
|
||||
line-height: normal;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
span.typed { color: #8a6d00; }
|
||||
`;
|
||||
root.appendChild(style);
|
||||
|
||||
const labels = makeLabels(targets.length);
|
||||
const hints = targets.map((t, i) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'hint';
|
||||
span.style.left = Math.max(0, t.rect.left) + 'px';
|
||||
span.style.top = Math.max(0, t.rect.top) + 'px';
|
||||
span.textContent = labels[i];
|
||||
root.appendChild(span);
|
||||
return { el: t.el, label: labels[i], span };
|
||||
});
|
||||
(document.documentElement || document.body).appendChild(host);
|
||||
|
||||
let typed = '';
|
||||
|
||||
function render() {
|
||||
for (const h of hints) {
|
||||
const match = h.label.startsWith(typed);
|
||||
h.span.style.display = match ? '' : 'none';
|
||||
if (!match)
|
||||
continue;
|
||||
h.span.textContent = '';
|
||||
if (typed) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'typed';
|
||||
t.textContent = typed;
|
||||
h.span.appendChild(t);
|
||||
}
|
||||
h.span.appendChild(document.createTextNode(h.label.slice(typed.length)));
|
||||
}
|
||||
}
|
||||
|
||||
function follow(h) {
|
||||
const el = h.el;
|
||||
if (!el.isConnected)
|
||||
return { state: 'gone' };
|
||||
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const r = visibleRect(el) || el.getBoundingClientRect();
|
||||
// att has no tabs, so a link meant for a new window would go nowhere.
|
||||
if ((el.tagName === 'A' || el.tagName === 'AREA') && el.target && !/^_(self|top|parent)$/i.test(el.target))
|
||||
el.target = '_self';
|
||||
const x = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1);
|
||||
const y = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1);
|
||||
return { state: 'follow', x, y, editable: isEditable(el) };
|
||||
}
|
||||
|
||||
window.__attHints = {
|
||||
key(ch) {
|
||||
const candidate = typed + ch;
|
||||
const matches = hints.filter(h => h.label.startsWith(candidate));
|
||||
if (matches.length === 0)
|
||||
return { state: 'nomatch' };
|
||||
typed = candidate;
|
||||
if (matches.length === 1) {
|
||||
this.clear();
|
||||
return follow(matches[0]);
|
||||
}
|
||||
render();
|
||||
return { state: 'partial' };
|
||||
},
|
||||
backspace() {
|
||||
typed = typed.slice(0, -1);
|
||||
render();
|
||||
return { state: 'partial' };
|
||||
},
|
||||
clear() {
|
||||
host.remove();
|
||||
if (window.__attHints === this)
|
||||
delete window.__attHints;
|
||||
return { state: 'none' };
|
||||
},
|
||||
};
|
||||
|
||||
if (hints.length === 0)
|
||||
window.__attHints.clear();
|
||||
return { state: 'shown', count: hints.length };
|
||||
})();
|
||||
)JS";
|
||||
|
||||
QString callScript(const QString &call)
|
||||
{
|
||||
return QStringLiteral("window.__attHints ? window.__attHints.%1 : { state: 'none' };")
|
||||
.arg(call);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace hints {
|
||||
|
||||
QString chars()
|
||||
{
|
||||
return QString::fromLatin1(kChars);
|
||||
}
|
||||
|
||||
QString showScript()
|
||||
{
|
||||
return QString::fromUtf8(kShowScript).replace(QLatin1String("@CHARS@"), chars());
|
||||
}
|
||||
|
||||
QString keyScript(QChar key)
|
||||
{
|
||||
// Only ever called with one of chars(), so no quoting is needed.
|
||||
return callScript(QStringLiteral("key('%1')").arg(key));
|
||||
}
|
||||
|
||||
QString backspaceScript()
|
||||
{
|
||||
return callScript(QStringLiteral("backspace()"));
|
||||
}
|
||||
|
||||
QString clearScript()
|
||||
{
|
||||
return callScript(QStringLiteral("clear()"));
|
||||
}
|
||||
|
||||
} // namespace hints
|
||||
Reference in New Issue
Block a user