Files

447 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {actions, defaults, groups, mouseValues, typesForAction, wheelValues} from './mapping.js';
import {exportableKeys} from './keys.js';
import {captureKey} from './capture.js';
import {parseIni, serialize, validate} from './validation.js';
import {diagnostic, initialLocale, locales, translate} from './i18n.js';
import {createDropdown} from './dropdown.js';
const byId = id => document.getElementById(id);
let entries = [];
let savedConfiguration = '[]';
let captureTarget;
let captureButton;
let selectedGroup = groups[0].name;
let locale = initialLocale();
let languageControl;
const bindingControls = new WeakMap();
let activeView = ['controls', 'configuration', 'help'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'controls';
let currentStatus;
let importReport;
const dialog = byId('capture-dialog');
try {
const savedLocale = localStorage.getItem('anyps5-controls-language');
locale = initialLocale(savedLocale);
} catch {}
const t = (key, values) => translate(locale, key, values);
const groupLabel = name => t(`group${groups.findIndex(group => group.name === name)}`);
function setStatus(key, values = {}) {
currentStatus = {key, values};
byId('status').textContent = t(key, values);
}
function showView(view, focus = true, updateHash = true) {
activeView = view;
const controls = view === 'controls';
byId('categories').hidden = !controls;
byId('toolbar').hidden = !controls;
byId('mappings').hidden = !controls;
byId('configuration').hidden = view !== 'configuration';
byId('help').hidden = view !== 'help';
byId('validation').hidden = view === 'help';
byId('status').hidden = view === 'help';
byId('import-report').hidden = view === 'help' || !importReport;
byId('view-intro').textContent = t(controls ? 'intro' : view === 'help' ? 'helpIntro' : 'configIntro');
if (controls) filterActions();
else byId('group-title').textContent = t(view);
for (const link of document.querySelectorAll('[data-view]')) {
const current = link.dataset.view === view;
link.classList.toggle('current', current);
if (current) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
if (updateHash && location.hash !== `#${view}`) history.pushState(null, '', `#${view}`);
if (focus) {
byId('content').focus({preventScroll: true});
window.scrollTo({top: 0, behavior: 'auto'});
}
}
function renderHelp() {
const opened = [...byId('help').querySelectorAll('details')].map(item => item.open);
byId('help').replaceChildren();
for (let index = 0; index < 4; index++) {
const question = element('details', undefined, 'faq');
question.open = opened[index] || false;
question.append(element('summary', t(`faq${index}Question`)), element('p', t(`faq${index}Answer`)));
byId('help').append(question);
}
}
function renderImportReport() {
if (!importReport) return;
const report = byId('import-report');
report.replaceChildren(element('h2', t(importReport.errors.length ? 'importRejected' : 'importRead')));
const messages = [...importReport.errors, ...importReport.warnings];
if (messages.length) {
const list = element('ul');
for (const item of messages) list.append(element('li', t('line', {line: item.line, message: diagnostic(locale, item.message)})));
report.append(list);
}
if (importReport.errors.length) report.append(element('p', t('preserved'), 'error'));
report.hidden = false;
}
function applyLocale() {
document.documentElement.lang = locale;
document.documentElement.dir = locales.find(item => item.code === locale).direction;
for (const node of document.querySelectorAll('[data-i18n]')) node.textContent = t(node.dataset.i18n);
for (const node of document.querySelectorAll('[data-i18n-label]')) node.setAttribute('aria-label', t(node.dataset.i18nLabel));
for (const node of document.querySelectorAll('[data-i18n-placeholder]')) node.placeholder = t(node.dataset.i18nPlaceholder);
languageControl.label = t('language');
languageControl.direction = document.documentElement.dir;
languageControl.text = dropdownText();
languageControl.setValue(locale);
byId('settings').title = t('language');
byId('categories').replaceChildren();
renderCategories();
renderHelp();
render();
renderImportReport();
if (currentStatus) setStatus(currentStatus.key, currentStatus.values);
showView(activeView, false, false);
updateClock();
}
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function isDirty() {
return JSON.stringify(entries) !== savedConfiguration;
}
async function confirmReplacement() {
if (!isDirty()) return true;
const confirmation = byId('replace-dialog');
confirmation.returnValue = 'cancel';
return new Promise(resolve => {
confirmation.addEventListener('close', () => resolve(confirmation.returnValue === 'discard'), {once: true});
confirmation.showModal();
});
}
function valueChoices(type) {
return type === 'KEY' ? exportableKeys : type === 'MOUSE' ? mouseValues : type === 'WHEEL' ? wheelValues : [];
}
function dropdownText() {
return {search: t('searchOptions'), close: t('close'), empty: t('noOptions')};
}
function selectControl(label, choices, value) {
return createDropdown({label, choices: [{value: '', label: t('select')}, ...choices], value, placeholder: t('select'), text: dropdownText()});
}
function button(text, label, click) {
const control = element('button', text);
control.type = 'button';
control.setAttribute('aria-label', label);
control.addEventListener('click', click);
return control;
}
function renderCategories() {
const icons = ['face-icon', 'shoulder-icon', 'direction-icon', 'stick-icon', 'stick-icon', 'touch-icon'];
const symbols = ['△ ○ × □', 'L2 R2', '', 'L', 'R', ''];
groups.forEach((group, index) => {
const category = button('', groupLabel(group.name), () => {
selectedGroup = group.name;
byId('search').value = '';
filterActions();
});
category.className = 'category';
category.dataset.group = group.name;
const icon = element('span', undefined, 'category-icon');
icon.setAttribute('aria-hidden', 'true');
const mark = element('span', undefined, icons[index]);
if (index === 0) {
for (const symbol of symbols[index].split(' ')) mark.append(element('span', symbol));
} else mark.textContent = symbols[index];
icon.append(mark);
category.append(icon, element('span', t(`group${index}`), 'category-label'));
category.addEventListener('keydown', event => {
const direction = document.documentElement.dir === 'rtl' ? -1 : 1;
const offset = event.key === 'ArrowRight' ? direction : event.key === 'ArrowLeft' ? -direction : 0;
if (!offset && event.key !== 'Home' && event.key !== 'End') return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? groups.length - 1 : (index + offset + groups.length) % groups.length;
const target = byId('categories').children[next];
target.click();
target.focus();
target.scrollIntoView({block: 'nearest', inline: 'nearest'});
});
byId('categories').append(category);
});
}
function renderBinding(entry, position) {
const row = element('div', undefined, 'binding');
const label = t('bindingLabel', {action: entry.action, number: position + 1});
const type = selectControl(t('typeLabel', {binding: label}), typesForAction(entry.action), entry.type);
const value = selectControl(t('valueLabel', {binding: label}), valueChoices(entry.type), entry.value);
type.element.dir = 'ltr';
value.element.dir = 'ltr';
const capture = button(t('capture'), t('captureLabel', {binding: label}), () => {
captureTarget = entry;
captureButton = capture;
byId('capture-title').textContent = t('captureTitle', {action: entry.action});
byId('capture-status').textContent = '';
dialog.showModal();
});
capture.disabled = entry.type !== 'KEY';
const remove = button(t('remove'), t('removeLabel', {binding: label}), () => {
entries = entries.filter(binding => binding !== entry);
render();
const remaining = document.querySelector(`[data-action="${entry.action}"] .dropdown-trigger`);
(remaining || document.querySelector(`[data-action="${entry.action}"] .add-binding`)).focus();
});
type.select.addEventListener('change', () => {
entry.type = type.select.value;
entry.value = '';
value.setChoices([{value: '', label: t('select')}, ...valueChoices(entry.type)]);
value.setValue('');
capture.disabled = entry.type !== 'KEY';
updateOutput();
});
value.select.addEventListener('change', () => {
entry.value = value.select.value;
updateOutput();
});
row.append(type.element, value.element, capture, remove);
bindingControls.set(entry, value);
return row;
}
function render() {
const container = byId('mappings');
container.replaceChildren();
for (const group of groups) {
const section = element('section', undefined, 'group');
section.dataset.group = group.name;
section.append(element('h2', groupLabel(group.name)));
for (const action of group.actions) {
const article = element('div', undefined, 'action');
article.dataset.action = action;
const heading = element('div', undefined, 'action-heading');
const add = button(t('add'), t('addLabel', {action}), () => {
entries.push({action, type: 'KEY', value: ''});
render();
const rows = document.querySelectorAll(`[data-action="${action}"] .binding`);
rows[rows.length - 1].querySelectorAll('.dropdown-trigger')[1].focus();
});
add.className = 'add-binding';
const title = element('div', undefined, 'action-title');
const symbols = {Cross: '×', Circle: '○', Triangle: '△', Square: '□', Up: '↑', Right: '→', Down: '↓', Left: '←'};
if (symbols[action]) {
const symbol = element('span', symbols[action], 'action-symbol');
symbol.setAttribute('aria-hidden', 'true');
title.append(symbol);
}
const actionName = element('h3', action);
actionName.dir = 'ltr';
title.append(actionName);
heading.append(title, add);
article.append(heading);
const configured = entries.filter(entry => entry.action === action);
article.append(element('p', configured.length ? t('custom') : t('builtin', {inputs: defaults[action].join(', ') || t('noDefault')}), 'hint'));
configured.forEach((entry, position) => article.append(renderBinding(entry, position)));
section.append(article);
}
container.append(section);
}
filterActions();
updateOutput();
}
function filterActions() {
const search = byId('search').value.trim().toLowerCase();
if (activeView === 'controls') byId('group-title').textContent = search ? t('search') : groupLabel(selectedGroup);
for (const category of byId('categories').children) {
category.setAttribute('aria-pressed', String(!search && category.dataset.group === selectedGroup));
}
let visible = 0;
for (const section of document.querySelectorAll('.group')) {
let matches = 0;
for (const action of section.querySelectorAll('.action')) {
action.hidden = search ? !`${section.dataset.group} ${groupLabel(section.dataset.group)} ${action.dataset.action}`.toLowerCase().includes(search) : section.dataset.group !== selectedGroup;
if (!action.hidden) matches++;
}
section.hidden = matches === 0;
visible += matches;
}
let empty = byId('no-results');
if (!empty) {
empty = element('p', t('noResults'), 'hint');
empty.id = 'no-results';
byId('mappings').append(empty);
}
empty.hidden = visible !== 0;
}
function updateOutput() {
const result = validate(entries);
const valid = result.errors.length === 0;
byId('preview').value = valid ? serialize(entries) : '';
byId('count').textContent = `${new Set(result.bindings.map(binding => binding.action)).size} / ${actions.length}`;
byId('download').disabled = !valid;
byId('copy').disabled = !valid;
const report = byId('validation');
report.replaceChildren();
for (const [name, messages] of [['error', result.errors], ['warning', result.warnings]]) {
if (!messages.length) continue;
const list = element('ul', undefined, name);
for (const item of messages) list.append(element('li', diagnostic(locale, item.message)));
report.append(list);
}
const rows = [...document.querySelectorAll('.binding')];
const renderedEntries = groups.flatMap(group => group.actions.flatMap(action => entries.filter(entry => entry.action === action)));
rows.forEach((row, index) => {
const invalid = result.errors.some(error => entries[error.index] === renderedEntries[index]);
for (const control of row.querySelectorAll('.dropdown-trigger')) control.setAttribute('aria-invalid', String(invalid));
});
}
function cancelCapture(message) {
captureTarget = undefined;
dialog.close();
captureButton?.focus();
if (message) setStatus(message);
}
byId('cancel-capture').addEventListener('click', () => cancelCapture('captureCancelled'));
dialog.addEventListener('cancel', event => {
event.preventDefault();
cancelCapture('captureCancelled');
});
window.addEventListener('blur', () => {
if (captureTarget) cancelCapture('captureBlur');
});
window.addEventListener('keydown', event => {
if (!captureTarget) return;
event.preventDefault();
event.stopImmediatePropagation();
if (event.code === 'Escape' || event.key === 'Escape') {
cancelCapture('captureCancelled');
return;
}
if (event.repeat) return;
const result = captureKey(event);
if (result.error) {
byId('capture-status').textContent = diagnostic(locale, result.error);
return;
}
const target = captureTarget;
target.value = result.value;
cancelCapture();
bindingControls.get(target)?.setValue(result.value);
updateOutput();
setStatus('captured', {value: result.value, action: target.action});
}, true);
byId('search').addEventListener('input', filterActions);
byId('reset').addEventListener('click', async () => {
if (!await confirmReplacement()) return;
entries = [];
savedConfiguration = '[]';
importReport = undefined;
byId('import-report').hidden = true;
render();
setStatus('resetDone');
});
byId('import').addEventListener('click', () => byId('file').click());
byId('file').addEventListener('change', async event => {
const file = event.target.files[0];
if (!file) return;
event.target.value = '';
try {
if (file.size > 1024 * 1024) throw new Error(t('fileTooLarge'));
const text = new TextDecoder('utf-8', {fatal: true}).decode(await file.arrayBuffer());
const result = parseIni(text);
const report = byId('import-report');
importReport = result;
renderImportReport();
if (result.errors.length) {
report.focus();
return;
}
if (!await confirmReplacement()) return;
entries = result.entries;
savedConfiguration = JSON.stringify(entries);
render();
setStatus('imported', {name: file.name});
} catch (error) {
setStatus('importFailed', {message: error instanceof TypeError ? t('invalidUtf8') : error.message});
}
});
byId('download').addEventListener('click', () => {
const content = serialize(entries);
const url = URL.createObjectURL(new Blob([content], {type: 'text/plain;charset=utf-8'}));
const link = element('a');
link.href = url;
link.download = 'anyps5-input.ini';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
savedConfiguration = JSON.stringify(entries);
setStatus('downloaded');
});
byId('copy').addEventListener('click', async () => {
try {
const content = serialize(entries);
const copiedConfiguration = JSON.stringify(entries);
await navigator.clipboard.writeText(content);
savedConfiguration = copiedConfiguration;
setStatus('copied');
} catch {
byId('preview').focus();
byId('preview').select();
setStatus('clipboardFailed');
}
});
window.addEventListener('beforeunload', event => {
if (!isDirty()) return;
event.preventDefault();
event.returnValue = '';
});
byId('focus-search').addEventListener('click', () => {
showView('controls');
byId('search').focus();
});
for (const link of document.querySelectorAll('[data-view]')) {
link.addEventListener('click', event => {
event.preventDefault();
showView(link.dataset.view);
});
}
byId('back-controls').addEventListener('click', () => showView('controls'));
function restoreView() {
const view = location.hash.slice(1);
if (['controls', 'configuration', 'help'].includes(view)) showView(view, false, false);
else if (!view) showView('controls', false, false);
}
window.addEventListener('popstate', restoreView);
window.addEventListener('hashchange', restoreView);
languageControl = createDropdown({
label: t('language'), choices: locales.map(item => ({value: item.code, label: item.name, lang: item.code, direction: item.direction})),
value: locale, placeholder: t('language'), text: dropdownText(), trigger: byId('settings'), direction: locales.find(item => item.code === locale).direction
});
byId('language-control').append(languageControl.element);
languageControl.select.addEventListener('change', event => {
locale = event.target.value;
try { localStorage.setItem('anyps5-controls-language', locale); } catch {}
applyLocale();
});
function updateClock() {
const now = new Date();
byId('clock').textContent = new Intl.DateTimeFormat(locale, {hour: '2-digit', minute: '2-digit'}).format(now);
byId('clock').dateTime = now.toISOString();
}
updateClock();
setInterval(updateClock, 60000);
applyLocale();