Files
att_browser/src/hints.cpp
T

292 lines
9.4 KiB
C++

#include "hints.h"
namespace {
constexpr auto kChars = "1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p";
// 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";
constexpr auto kFocusInputScript = R"JS(
(function () {
'use strict';
const TEXT_INPUTS = new Set(['', 'text', 'search', 'email', 'url', 'tel',
'password', 'number']);
function isTextField(el) {
if (el.disabled || el.readOnly)
return false;
if (el.tagName === 'TEXTAREA')
return true;
// Only the root of an editable region, not every element inside it.
if (el.isContentEditable)
return !el.parentElement?.isContentEditable;
return el.tagName === 'INPUT' && TEXT_INPUTS.has((el.getAttribute('type') || '').toLowerCase());
}
function isVisible(el) {
const style = getComputedStyle(el);
if (style.visibility !== 'visible' || style.display === 'none')
return false;
const r = el.getBoundingClientRect();
return r.width >= 1 && r.height >= 1;
}
function inViewport(el) {
const r = el.getBoundingClientRect();
return r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth;
}
// Document order, descending into each open shadow root right after its host.
function* elements(root) {
for (const el of root.querySelectorAll('*')) {
yield el;
if (el.shadowRoot)
yield* elements(el.shadowRoot);
}
}
let fallback = null;
let target = null;
for (const el of elements(document)) {
if (!isTextField(el) || !isVisible(el))
continue;
if (inViewport(el)) {
target = el;
break;
}
fallback = fallback || el;
}
target = target || fallback;
if (!target)
return false;
target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
target.focus();
// Put the cursor after any existing text, ready to type or edit.
if (typeof target.setSelectionRange === 'function') {
try {
target.setSelectionRange(target.value.length, target.value.length);
} catch (e) {
// type=email/number don't support selection ranges.
}
}
// From outside a shadow root, document.activeElement is only its host.
return target.getRootNode().activeElement === target;
})();
)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()"));
}
QString focusInputScript()
{
return QString::fromUtf8(kFocusInputScript);
}
} // namespace hints