mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-03 08:33:46 -06:00
chore: merge folded cards and one install command
This commit is contained in:
commit
793b16cf67
15 files changed
+974
-66
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.
|
||||
@@ -957,6 +958,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', () => {
|
||||
@@ -981,5 +1171,7 @@ breakPaths();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
osTabs();
|
||||
installPickers();
|
||||
copyButtons();
|
||||
showCurrentPage();
|
||||
notFound();
|
||||
@@ -17,6 +17,9 @@
|
||||
.rb-bar inline coverage bar, width from --v
|
||||
.rb-details-* collapsible block, neutral unless caution or danger
|
||||
dl.rb-facts the facts of a file card, label and value side by side
|
||||
.rb-install-all the list of every command in a system's install box
|
||||
.rb-copy the copy button site.ts adds after a hash
|
||||
.rb-install-setup the setup list of a system's install box
|
||||
abbr.rb-term a glossary term, its definition on hover or tap
|
||||
*/
|
||||
|
||||
@@ -568,6 +571,50 @@ body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); }
|
||||
.sl-markdown-content details.rb-entry ul { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
/* A fold of a file card (Hashes, the rest): no frame, a small summary in
|
||||
the muted colour, a 24 px target (WCAG 2.5.8). */
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) { margin: 0.3rem 0 0; padding: 0; border: 0; font-size: 0.85rem; }
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary {
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--rb-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary::before { background-color: var(--rb-text-secondary); }
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary:hover { color: var(--sl-color-white); }
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > :not(summary) { margin-top: 0.2rem; }
|
||||
|
||||
/* The copy button after a hash (site.ts): the Download button's frame,
|
||||
smaller, a 24 px target that stays inside a 24 px line, so it never
|
||||
covers the fold below it (WCAG 2.5.8). */
|
||||
.rb-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 24px;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: var(--sl-font);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--rb-link);
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rb-copy:hover { background: var(--sl-color-accent-low); }
|
||||
|
||||
/* A system's install box: the setup list above the OS tabs, and the list
|
||||
of every command, which wraps anywhere rather than scroll a phone. */
|
||||
.sl-markdown-content .rb-install-all code { overflow-wrap: anywhere; }
|
||||
.sl-markdown-content .rb-install-setup { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
|
||||
/* Its select is styled as every other choice list on the site (the
|
||||
arrow of --rb-select-arrow, below): a rule of its own hid the arrow. */
|
||||
|
||||
/* Collapsible blocks are quiet: a closed block must not outweigh the
|
||||
command it explains. Colour is kept for caution and danger. */
|
||||
.sl-markdown-content details.rb-details {
|
||||
|
||||
Reference in new issue
Block a user