292 lines
9.4 KiB
C++
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
|