mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-28 12:05:06 +00:00
447 lines
18 KiB
JavaScript
447 lines
18 KiB
JavaScript
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();
|