mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-15 06:23:47 -06:00
feat: fold card hashes, one install command
System and emulator file cards show the name, size, download, a short SHA1 with a copy button, the setups and the emulators. The full hashes and the other facts sit in one closed fold, each hash with a copy button. A system's install box shows one command under the OS tabs for the setup picked in a list, remembered across pages. Without the script the list of every command shows, open. The Markdown the page budgets are measured on is unchanged, so no card moves.
This commit is contained in:
1 parent
7ec4d00375
commit
8adb84696d
13 files changed
+929
-67
No files matched your search
+193
-1
@@ -1,7 +1,8 @@
|
||||
// Progressive enhancement of the generated pages: table captions, filters
|
||||
// and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file
|
||||
// hashed on this device), moved cards, a hash typed in the search, glossary
|
||||
// terms on a tap, the OS tab of the visitor's system, the current page in
|
||||
// terms on a tap, the OS tab of the visitor's system, the setup picked in an
|
||||
// install box, copy buttons on hashes, the current page in
|
||||
// the sidebar, and the 404 page's hints.
|
||||
// Which tables stack on a phone is decided when the page is built
|
||||
// (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint.
|
||||
@@ -931,6 +932,195 @@ const glossaryTerms = () => {
|
||||
document.addEventListener('click', close);
|
||||
};
|
||||
|
||||
// Copy buttons. A system card's hash cell (dd.rb-hash) gets one: in its
|
||||
// fold, each hash copies itself, and the short SHA1 shown open copies the
|
||||
// whole value, the fold's first hash. An emulator card's hash and each
|
||||
// command of an install box's list carry data-copy: the value to copy, or
|
||||
// empty when the text shown is the value. The buttons are added where the
|
||||
// reader sees the values, and when a fold opens for the ones inside it, so a
|
||||
// page of 250 closed folds holds none of them.
|
||||
const COPYABLE = '[data-copy], .rb-sys-file dd.rb-hash';
|
||||
|
||||
// What the button copies, in words: the label of its grid row, else the
|
||||
// words before the value ("MD5: ", ", SHA1 ", "RetroArch: "), else the
|
||||
// label its list item opens on.
|
||||
const copyLabel = (value: HTMLElement) => {
|
||||
const term = (value.tagName === 'DD' ? value : value.closest('dd'))?.previousElementSibling;
|
||||
if (term?.tagName === 'DT') return term.textContent?.trim() ?? '';
|
||||
const before = (value.previousSibling?.textContent ?? '').replace(/[:\s]+$/, '').split(',').pop()?.trim();
|
||||
if (before) return before;
|
||||
return /^\s*([^:]+):/.exec(value.closest('li')?.textContent ?? '')?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
// The value's own text, without the button a cell holds.
|
||||
const shownText = (value: Element) =>
|
||||
[...value.childNodes].filter((node) => !(node instanceof HTMLButtonElement)).map((node) => node.textContent).join('').trim();
|
||||
|
||||
const copyText = (value: HTMLElement) => {
|
||||
const own = value.dataset.copy;
|
||||
// An MDX page writes an empty attribute as "true".
|
||||
if (own && own !== 'true') return own;
|
||||
if (value.matches('dd.rb-hash') && !value.closest('details')) {
|
||||
const whole = value.closest('.rb-sys-file')?.querySelector('details dd.rb-hash');
|
||||
if (whole) return shownText(whole);
|
||||
}
|
||||
return shownText(value);
|
||||
};
|
||||
|
||||
const addCopyButtons = (root: ParentNode) => {
|
||||
for (const value of root.querySelectorAll<HTMLElement>(COPYABLE)) {
|
||||
if (value.hasAttribute('data-copy-ready') || value.closest('details:not([open])')) continue;
|
||||
value.setAttribute('data-copy-ready', '');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'rb-copy';
|
||||
button.textContent = 'Copy';
|
||||
// The word that replaces "Copy" is read out.
|
||||
button.setAttribute('aria-live', 'polite');
|
||||
const label = copyLabel(value);
|
||||
button.setAttribute('aria-label', label ? `Copy ${label}` : 'Copy');
|
||||
// A grid cell holds its button, a value elsewhere is followed by it.
|
||||
if (value.tagName === 'DD') value.append(button);
|
||||
else value.after(button);
|
||||
}
|
||||
};
|
||||
|
||||
const copyButtons = () => {
|
||||
if (!document.querySelector(COPYABLE)) return;
|
||||
addCopyButtons(document);
|
||||
document.addEventListener('toggle', (event) => {
|
||||
const fold = event.target as HTMLDetailsElement;
|
||||
if (fold.open) addCopyButtons(fold);
|
||||
}, true);
|
||||
document.addEventListener('click', async (event) => {
|
||||
const button = (event.target as Element).closest?.<HTMLButtonElement>('button.rb-copy');
|
||||
if (!button) return;
|
||||
const value = button.parentElement?.matches(COPYABLE)
|
||||
? button.parentElement
|
||||
: (button.previousElementSibling as HTMLElement | null);
|
||||
if (!value?.matches(COPYABLE)) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText(value));
|
||||
button.textContent = 'Copied';
|
||||
} catch {
|
||||
// No clipboard (an insecure page, a refused permission): the value
|
||||
// is selected, for Ctrl+C.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(value.tagName === 'DD' && value.firstChild ? value.firstChild : value);
|
||||
getSelection()?.removeAllRanges();
|
||||
getSelection()?.addRange(range);
|
||||
button.textContent = 'Selected';
|
||||
}
|
||||
window.setTimeout(() => { button.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// A system's install box: one command, for the setup picked in its list,
|
||||
// under the site's OS tabs. The box is built with the first setup's command;
|
||||
// the choice rewrites the options of each command and its copy text, and is
|
||||
// remembered for the next box and the next page. Without this script the
|
||||
// picker stays hidden and the list of every command shows instead.
|
||||
const SETUP_KEY = 'rb-setup';
|
||||
|
||||
const savedSetup = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(SETUP_KEY);
|
||||
} catch {
|
||||
// Storage refused (a private window, blocked site data): no saved setup.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// The colours a flag and a value take in the highlighted sh command, read
|
||||
// from the box's own first command so a rewritten one looks the same.
|
||||
type ArgStyles = { flag: string; value: string; space: string };
|
||||
|
||||
const argStyles = (block: Element): ArgStyles | null => {
|
||||
const leaves = [...block.querySelectorAll<HTMLElement>('pre[data-language="sh"] .ec-line .code span')].filter((span) => !span.children.length);
|
||||
const at = leaves.findIndex((leaf) => leaf.textContent === '--platform');
|
||||
if (at < 0) return null;
|
||||
const value = leaves.slice(at + 1).find((leaf) => leaf.textContent?.trim());
|
||||
const space = leaves.find((leaf) => leaf.textContent === ' ');
|
||||
return { flag: leaves[at].getAttribute('style') ?? '', value: value?.getAttribute('style') ?? '', space: space?.getAttribute('style') ?? '' };
|
||||
};
|
||||
|
||||
// Rewrites the options of a highlighted command, from " --platform": the
|
||||
// installer's own part keeps its colours, the options take *styles*.
|
||||
const setCommandArgs = (frame: Element, args: string, styles: ArgStyles | null) => {
|
||||
const line = frame.querySelector<HTMLElement>('.ec-line .code');
|
||||
if (!line) return;
|
||||
const text = line.textContent ?? '';
|
||||
const start = text.indexOf(' --platform ');
|
||||
if (start < 0) return;
|
||||
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT);
|
||||
let seen = 0;
|
||||
const range = document.createRange();
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const length = node.textContent?.length ?? 0;
|
||||
if (seen + length > start) {
|
||||
range.setStart(node, start - seen);
|
||||
break;
|
||||
}
|
||||
seen += length;
|
||||
}
|
||||
range.setEndAfter(line.lastChild!);
|
||||
range.deleteContents();
|
||||
for (const part of ` ${args}`.split(/(\s+)/)) {
|
||||
if (!part) continue;
|
||||
const span = document.createElement('span');
|
||||
const space = /^\s+$/.test(part);
|
||||
const style = space ? styles?.space : part.startsWith('-') ? styles?.flag : styles?.value;
|
||||
if (style) span.setAttribute('style', style);
|
||||
span.textContent = part;
|
||||
// The theme colours a span that has no class: a word kept whole is
|
||||
// wrapped, as the build wraps it (ec-unbroken-tokens.mjs).
|
||||
if (space) {
|
||||
line.append(span);
|
||||
} else {
|
||||
const whole = document.createElement('span');
|
||||
whole.className = 'rb-nb';
|
||||
whole.append(span);
|
||||
line.append(whole);
|
||||
}
|
||||
}
|
||||
frame.querySelector('.copy button')?.setAttribute('data-code', text.slice(0, start) + ` ${args}`);
|
||||
};
|
||||
|
||||
const installPickers = () => {
|
||||
const blocks = [...document.querySelectorAll<HTMLElement>('[data-rb-install]')];
|
||||
if (!blocks.length) return;
|
||||
const pick = (setup: string | null) => {
|
||||
for (const block of blocks) {
|
||||
const select = block.querySelector('select');
|
||||
if (!select) continue;
|
||||
if (setup && [...select.options].some((option) => option.value === setup)) select.value = setup;
|
||||
const option = select.selectedOptions[0];
|
||||
if (!option) continue;
|
||||
const styles = (block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles;
|
||||
for (const frame of block.querySelectorAll('.expressive-code')) {
|
||||
const shell = frame.querySelector('pre')?.dataset.language === 'powershell' ? 'ps' : 'sh';
|
||||
setCommandArgs(frame, option.dataset[shell] ?? '', styles ?? null);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const block of blocks) {
|
||||
(block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles = argStyles(block);
|
||||
block.hidden = false;
|
||||
const list = block.nextElementSibling;
|
||||
if (list instanceof HTMLDetailsElement && list.classList.contains('rb-install-all')) list.open = false;
|
||||
block.querySelector('select')?.addEventListener('change', (event) => {
|
||||
const setup = (event.target as HTMLSelectElement).value;
|
||||
try {
|
||||
localStorage.setItem(SETUP_KEY, setup);
|
||||
} catch {
|
||||
// Storage refused: the choice holds for this page only.
|
||||
}
|
||||
pick(setup);
|
||||
});
|
||||
}
|
||||
pick(savedSetup());
|
||||
};
|
||||
|
||||
// The 404 page: a search button, and the list a moved page belongs to.
|
||||
const notFound = () => {
|
||||
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
|
||||
@@ -953,5 +1143,7 @@ breakPaths();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
osTabs();
|
||||
installPickers();
|
||||
copyButtons();
|
||||
showCurrentPage();
|
||||
notFound();
|
||||
Reference in new issue
Block a user