mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
190 lines
7.3 KiB
JavaScript
190 lines
7.3 KiB
JavaScript
/** Restoring a previous run into the questionnaire.
|
|
*
|
|
* The one rule this file is built around: setting `.checked` or `.value` from
|
|
* script fires no event, and every committer on this page runs off change
|
|
* events. A bare write therefore leaves the radio saying one thing and the
|
|
* hidden field the rest of the run reads saying another. So every answer is
|
|
* restored by clicking its control, or by calling the function the control
|
|
* would have called.
|
|
*
|
|
* The order matters too. Clicking a radio runs record(), which stamps
|
|
* data-chosen on every field it writes, so which answers a person actually
|
|
* visited is restored last, after every click has had its say.
|
|
*
|
|
* The questionnaire owns the state being restored, so palette-picker.js passes
|
|
* the handles this needs rather than this file reaching into it.
|
|
*/
|
|
|
|
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
|
|
|
|
/* Groups whose answer is one radio the whole run reads. Absent keys are left
|
|
alone: a run that was never asked about movement must come back without a
|
|
movement key, not with a default standing in for a decision. */
|
|
const RADIO_GROUPS = [
|
|
'color-strategy',
|
|
'motion-energy',
|
|
'layout-structure',
|
|
'boundary-style',
|
|
'corner-style',
|
|
'depth-style',
|
|
'type-scale',
|
|
'icon-pack',
|
|
];
|
|
|
|
const asText = (value) => (typeof value === 'string' ? value : '');
|
|
|
|
/* Clicking is the whole point: activation runs the change handlers that commit
|
|
the answer, which a bare `checked = true` would skip. */
|
|
function clickOption(name, value) {
|
|
if (!value) return false;
|
|
const input = document.querySelector(`input[name="${name}"][value="${CSS.escape(value)}"]`);
|
|
if (!input || input.disabled) return false;
|
|
input.click();
|
|
return true;
|
|
}
|
|
|
|
function surfaceFields() {
|
|
return [...document.querySelectorAll('input[type="hidden"][data-surface-field]')];
|
|
}
|
|
|
|
export function hydrateAnswers(prior, ctx) {
|
|
if (!prior) return;
|
|
|
|
restoreSurfaces(prior, ctx);
|
|
for (const group of RADIO_GROUPS) {
|
|
if (group in prior) clickOption(group, asText(prior[group]));
|
|
}
|
|
restoreSurfaceFields(prior);
|
|
restorePalette(prior, ctx);
|
|
restoreFonts(prior, ctx);
|
|
restoreIconMeta(prior);
|
|
restoreChosen(prior, ctx);
|
|
}
|
|
|
|
/* Which surfaces the run covers, first, because every per-surface field is
|
|
filled with its default the moment a tile is checked. */
|
|
function restoreSurfaces(prior, ctx) {
|
|
const raw = prior['surface-modes'];
|
|
const wanted = new Set(Array.isArray(raw) ? raw : (asText(raw) ? [raw] : []));
|
|
if (!wanted.size) return;
|
|
// The same guard the agent's own hint carries: a set naming no real tile
|
|
// would otherwise clear the run's only required answer.
|
|
if (!ctx.modeInputs.some((input) => wanted.has(input.value))) return;
|
|
for (const input of ctx.modeInputs) input.checked = wanted.has(input.value);
|
|
ctx.syncModes();
|
|
}
|
|
|
|
function restoreSurfaceFields(prior) {
|
|
for (const field of surfaceFields()) {
|
|
const key = field.dataset.surfaceField;
|
|
if (field.disabled || !(key in prior)) continue;
|
|
const value = asText(prior[key]);
|
|
if (value) field.value = value;
|
|
}
|
|
}
|
|
|
|
/* The deck has no programmatic selection path: the committing click reads the
|
|
card the scroller is parked on. So the card's own state is written first,
|
|
which is also what stops a later reorder or reset from reverting the fields
|
|
to the colors the cue was dealt with. */
|
|
function restorePalette(prior, ctx) {
|
|
const source = asText(prior['palette-source']);
|
|
const colors = {};
|
|
for (const role of ROLES) {
|
|
const hex = asText(prior[`palette-${role}`]);
|
|
if (hex) colors[role] = hex;
|
|
}
|
|
if (!source && !Object.keys(colors).length) return;
|
|
|
|
/* The fields come first, and unconditionally: the colors are the answer, the
|
|
source is what the document names them by, and neither depends on the deck
|
|
still being able to show the card they came from. A document reopened after
|
|
the generation workspace was cleaned has no deck at all, and it still has a
|
|
palette. */
|
|
const sourceField = document.querySelector('[name="palette-source"]');
|
|
if (sourceField && source) sourceField.value = source;
|
|
for (const role of ROLES) {
|
|
const field = document.querySelector(`[name="palette-${role}"]`);
|
|
if (field && colors[role]) field.value = colors[role];
|
|
}
|
|
|
|
// Nothing was dealt, so there is no card to park on and nothing to repaint.
|
|
if (!ctx.cards.length) return;
|
|
|
|
const index = ctx.cards.findIndex((item) => item.id === source);
|
|
const target = index === -1 ? 0 : index;
|
|
/* Writing the card's own state is also what stops a later reorder or reset
|
|
from reverting the fields to the colors the cue was dealt with. */
|
|
const state = ctx.states.get(ctx.cards[target]?.id);
|
|
if (state?.colors) Object.assign(state.colors, colors);
|
|
ctx.setCurrent(target);
|
|
ctx.render();
|
|
ctx.syncDeckScroll();
|
|
}
|
|
|
|
/* A pair still on the rail is chosen by clicking it, which runs syncFontPair.
|
|
A pair that is not, an upload or a set of faces this run was not dealt, is
|
|
rebuilt as the custom pair and registered before its card is added: the rail
|
|
resolves a click through the manifest, so a card the manifest does not know
|
|
cannot be chosen a second time. */
|
|
function restoreFonts(prior, ctx) {
|
|
const wanted = asText(prior['font-pair']);
|
|
const heading = asText(prior['font-heading']);
|
|
const body = asText(prior['font-body']);
|
|
const manifest = ctx.fontManifest();
|
|
|
|
if (wanted && wanted !== 'custom' && manifest.pairs.some(({ id }) => id === wanted)) {
|
|
if (clickOption('font-pair', wanted)) return;
|
|
}
|
|
if (!heading || !body) return;
|
|
|
|
const pair = {
|
|
id: 'custom',
|
|
name: 'Custom',
|
|
heading: { family: heading, weight: 600, source: asText(prior['font-heading-source']) },
|
|
body: { family: body, weight: 400, source: asText(prior['font-body-source']) },
|
|
why: 'From your last run',
|
|
};
|
|
manifest.pairs = [pair, ...manifest.pairs.filter(({ id }) => id !== 'custom')];
|
|
for (const node of ctx.pairNodes()) {
|
|
if (node.querySelector('input')?.value === 'custom') ctx.removePairCard(node);
|
|
}
|
|
ctx.addPairCard(pair, { checked: true, first: true });
|
|
ctx.loadCustomFace(pair);
|
|
ctx.syncFontPair(pair);
|
|
ctx.applyHoist({ force: true });
|
|
}
|
|
|
|
/* The pack's own radio carries the license and URL when it is still on offer;
|
|
these are the record of one that is not. */
|
|
function restoreIconMeta(prior) {
|
|
for (const key of ['icon-pack-name', 'icon-pack-license', 'icon-pack-url']) {
|
|
const field = document.querySelector(`[name="${key}"]`);
|
|
const value = asText(prior[key]);
|
|
if (field && !field.value && value) field.value = value;
|
|
}
|
|
}
|
|
|
|
/* Last, because every click above stamped its own. A default nobody opened and
|
|
a default someone confirmed hold the same value, so only this list tells them
|
|
apart, and the document says which is which. */
|
|
function restoreChosen(prior, ctx) {
|
|
let chosen = null;
|
|
try {
|
|
const parsed = JSON.parse(asText(prior._chosen) || 'null');
|
|
if (Array.isArray(parsed)) chosen = new Set(parsed);
|
|
} catch {
|
|
/* Written by a run that did not keep the distinction. */
|
|
}
|
|
for (const field of surfaceFields()) {
|
|
const key = field.dataset.surfaceField;
|
|
if (field.disabled) continue;
|
|
// A run that kept no list was confirmed wholesale at submit, so every
|
|
// answer it carries counts as visited.
|
|
const visited = chosen ? chosen.has(key) : key in prior;
|
|
if (visited) field.dataset.chosen = 'yes';
|
|
else delete field.dataset.chosen;
|
|
}
|
|
ctx.syncChosenField();
|
|
}
|