324 lines
9.2 KiB
TypeScript
324 lines
9.2 KiB
TypeScript
import * as Color from './color.js';
|
|
import { History } from './history.js';
|
|
import * as Layer from './layer.js';
|
|
import * as Palette from './palette.js';
|
|
|
|
|
|
function message(msg: string, size = 100) {
|
|
const ctx = getCanvasCtx('main');
|
|
ctx.save();
|
|
ctx.clearRect(0, 0, Layer.WIDTH, Layer.HEIGHT);
|
|
ctx.font = `bold ${size}px Muller, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(msg, Layer.WIDTH/2, Layer.HEIGHT/2, Layer.WIDTH-10);
|
|
ctx.restore();
|
|
}
|
|
|
|
|
|
function urlState(): string | undefined {
|
|
const hash = document.location.hash?.substring(1);
|
|
if (hash != '' && hash !== undefined) return decodeURI(hash);
|
|
}
|
|
|
|
function updateUrl(seed: string): void {
|
|
history.replaceState({}, '', `#${encodeURI(seed)}`);
|
|
}
|
|
|
|
type ApplyStateOpts = {
|
|
seed: string,
|
|
side?: Layer.Side,
|
|
firstLoad?: boolean,
|
|
history?: History,
|
|
done?: Done,
|
|
};
|
|
|
|
async function
|
|
applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
|
|
const { seed, history } = opts;
|
|
let { side, firstLoad, done } = opts;
|
|
side ??= 'front';
|
|
firstLoad ??= false;
|
|
done ??= () => {};
|
|
|
|
const rand = new Color.Rand(seed);
|
|
|
|
const cols = Color.colors(rand, Color.KNOWN[seed]);
|
|
const newSeed = rand.alphaNum();
|
|
|
|
await Layer.recolorAll(data, cols);
|
|
|
|
updateBg(cols);
|
|
updateSvgs(cols);
|
|
updateLabel(seed);
|
|
updateUrl(seed);
|
|
|
|
if (firstLoad) {
|
|
await instantUpdateImage(side, data);
|
|
done();
|
|
} else {
|
|
await animateUpdateImage(side, data, done);
|
|
}
|
|
|
|
if (history) history.addSave(seed);
|
|
|
|
return newSeed;
|
|
}
|
|
|
|
type CanvasId = 'main' | 'aux';
|
|
|
|
function getCanvasCtx(id: CanvasId) {
|
|
const canvas = document.getElementById(id) as HTMLCanvasElement;
|
|
return canvas.getContext('2d')!;
|
|
}
|
|
|
|
async function
|
|
instantUpdateImage(side: Layer.Side, data: Layer.Data) {
|
|
const cdata = await Layer.ensureComposed(getCanvasCtx('aux'), data);
|
|
getCanvasCtx('main').putImageData(cdata[`${side}Image`], 0, 0);
|
|
}
|
|
|
|
type Done = () => void;
|
|
|
|
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
|
|
|
|
async function
|
|
animateUpdateImage(side: Layer.Side, data: Layer.Data, done: Done) {
|
|
if (noAnim.matches) {
|
|
await instantUpdateImage(side, data);
|
|
done();
|
|
return;
|
|
}
|
|
|
|
const duration = 200;
|
|
|
|
const main = getCanvasCtx('main');
|
|
const aux = getCanvasCtx('aux');
|
|
|
|
document.documentElement.dataset.running = 'reroll';
|
|
const cdata = await Layer.ensureComposed(aux, data);
|
|
Layer.redraw(aux, cdata, side);
|
|
|
|
aux.canvas.addEventListener('animationend', () => {
|
|
Layer.redraw(main, cdata, side);
|
|
aux.canvas.style.removeProperty('animation');
|
|
delete document.documentElement.dataset.running;
|
|
done();
|
|
});
|
|
|
|
setTimeout(() =>
|
|
aux.canvas.style.animation = `${duration}ms ease forwards fade-in`
|
|
);
|
|
}
|
|
|
|
async function
|
|
animateSwapImage(newSide: Layer.Side, data: Layer.ComposedData, done: Done) {
|
|
if (noAnim.matches) {
|
|
await instantUpdateImage(newSide, data);
|
|
done();
|
|
return;
|
|
}
|
|
|
|
const duration = 400;
|
|
|
|
const main = getCanvasCtx('main');
|
|
const aux = getCanvasCtx('aux');
|
|
|
|
document.documentElement.dataset.running = 'swap';
|
|
Layer.redraw(aux, data, newSide);
|
|
|
|
aux.canvas.addEventListener('animationend', () => {
|
|
const image = aux.getImageData(0, 0, Layer.WIDTH, Layer.HEIGHT);
|
|
main.putImageData(image, 0, 0);
|
|
|
|
main.canvas.style.removeProperty('animation');
|
|
aux.canvas.style.removeProperty('animation');
|
|
delete document.documentElement.dataset.running;
|
|
done();
|
|
});
|
|
|
|
main.canvas.style.animation = `${duration}ms ease swap1`;
|
|
aux.canvas.style.animation = `${duration}ms ease swap2`;
|
|
}
|
|
|
|
function updateBg(cols: Color.Colors) {
|
|
document.documentElement.style.setProperty('--hue', `${cols.outer.hue}`);
|
|
}
|
|
|
|
function updateLabel(seed: string) {
|
|
const stateLabel = document.getElementById('current-name');
|
|
if (stateLabel) stateLabel.innerHTML = seed;
|
|
}
|
|
|
|
function updateSvgs(cols: Color.Colors) {
|
|
const paletteObj = document.getElementById('palette') as HTMLObjectElement;
|
|
const palette = paletteObj.contentDocument as XMLDocument | null;
|
|
|
|
if (palette) {
|
|
palette.documentElement.style.setProperty('--hue', `${cols.outer.hue}`);
|
|
const get = (id: string) => palette.getElementById(id);
|
|
|
|
for (const layer of Color.allLayers) {
|
|
const col = cols[layer].css();
|
|
let elem;
|
|
|
|
// main group
|
|
if ((elem = get(`i-${layer}`))) {
|
|
if (cols[layer].luma < 0.6) {
|
|
elem.classList.add('light'); elem.classList.remove('dark');
|
|
} else {
|
|
elem.classList.add('dark'); elem.classList.remove('light');
|
|
}
|
|
elem.style.setProperty('--col', col);
|
|
}
|
|
|
|
// label
|
|
if ((elem = get(`c-${layer}`))) elem.innerHTML = col;
|
|
// minor swatch, if applicable
|
|
if ((elem = get(`s-${layer}`))) elem.style.setProperty('--col', col);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function showHistory(history: History, data: Layer.Data,
|
|
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
|
|
const list = document.getElementById('history-items');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
const { side, firstLoad, done } = opts;
|
|
|
|
for (const item of history.iterItems()) {
|
|
const elem = item.asHtml();
|
|
const allOpts = { side, firstLoad, done, seed: item.name, history };
|
|
elem.addEventListener('click', () => void applyState(data, allOpts));
|
|
list.appendChild(elem);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
document.documentElement.dataset.state = 'history';
|
|
const elem = document.createElement('div');
|
|
elem.id = 'history-close-target';
|
|
elem.addEventListener('click', closeHistory);
|
|
document.body.appendChild(elem);
|
|
});
|
|
}
|
|
|
|
function closeHistory() {
|
|
document.getElementById('history-items')?.
|
|
scroll({ top: 0, left: 0, behavior: 'smooth' });
|
|
const field = document.getElementById('history-close-target');
|
|
if (field) field.parentElement?.removeChild(field);
|
|
document.documentElement.dataset.state = 'ready';
|
|
}
|
|
|
|
function download(seed: string) {
|
|
const colors = Color.colors(new Color.Rand(seed));
|
|
const blob = Palette.make(seed, colors);
|
|
|
|
// there must be a better way to push out a file than
|
|
// this autohotkey-ass nonsense
|
|
const elem = document.createElement('a');
|
|
elem.download = `quox-${seed}.gpl`;
|
|
const url = URL.createObjectURL(blob);
|
|
elem.href = url;
|
|
elem.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function setup() {
|
|
message('loading layers…');
|
|
|
|
const aux = getCanvasCtx('aux');
|
|
|
|
const data = await Layer.loadData(aux)
|
|
.catch(e => { message(`${e}`, 30); throw e });
|
|
const history = History.load();
|
|
|
|
let prevSeed = urlState() ?? new Color.Rand().alphaNum();
|
|
let seed =
|
|
await applyState(data, { seed: prevSeed, history, firstLoad: true });
|
|
let side: Layer.Side = 'front';
|
|
|
|
const reroll = document.getElementById('reroll')!;
|
|
const swap = document.getElementById('swap')!;
|
|
|
|
addListeners();
|
|
|
|
function asyncHandler(h: (e: Event) => Promise<void>): (e: Event) => void {
|
|
return (e: Event) => void h(e);
|
|
}
|
|
|
|
// these ones don't need to be toggled
|
|
document.getElementById('hideui')?.addEventListener('click', () => {
|
|
document.documentElement.dataset.state = 'fullquox';
|
|
});
|
|
document.getElementById('showui')?.addEventListener('click', () => {
|
|
document.documentElement.dataset.state = 'ready';
|
|
});
|
|
document.getElementById('history-button')?.addEventListener('click', () => {
|
|
// does this need the add/remove listeners dance
|
|
// actually does anything any more?
|
|
showHistory(history, data, { side });
|
|
});
|
|
document.getElementById('close-history')?.addEventListener('click', closeHistory);
|
|
document.getElementById('current-name')?.addEventListener('focusout', asyncHandler(async e => {
|
|
const space = String.raw`(\n|\s|<br>| )`;
|
|
const re = new RegExp(`^${space}+|${space}+$`, 'msgu');
|
|
|
|
const elem = e.target as HTMLElement;
|
|
let str = elem.innerText.replaceAll(re, '');
|
|
if (!str) str = new Color.Rand().alphaNum();
|
|
elem.innerText = str;
|
|
// todo allow images cos it's funny
|
|
|
|
prevSeed = seed;
|
|
seed = await applyState(data, { side, seed: str, history });
|
|
}));
|
|
document.getElementById('download-button')?.addEventListener('click', () => {
|
|
download(prevSeed);
|
|
});
|
|
|
|
document.documentElement.dataset.state = 'ready';
|
|
|
|
function run(task: (k: Done) => Promise<void>): void {
|
|
removeListeners();
|
|
void task(addListeners);
|
|
}
|
|
|
|
function updateFromUrl() {
|
|
run(async k => {
|
|
const newSeed = urlState();
|
|
if (newSeed) {
|
|
const opts = { history, side, seed: newSeed, done: k };
|
|
prevSeed = seed;
|
|
seed = await applyState(data, opts);
|
|
}
|
|
});
|
|
}
|
|
function runReroll() {
|
|
run(async k => {
|
|
prevSeed = seed;
|
|
seed = await applyState(data, { side, seed, history, done: k });
|
|
});
|
|
}
|
|
function runSwap() {
|
|
run(async k => {
|
|
side = Layer.swapSide(side);
|
|
const cdata = await Layer.ensureComposed(aux, data);
|
|
await animateSwapImage(side, cdata, k);
|
|
});
|
|
}
|
|
|
|
function addListeners() {
|
|
window.addEventListener('hashchange', updateFromUrl);
|
|
reroll.addEventListener('click', runReroll);
|
|
swap.addEventListener('click', runSwap);
|
|
}
|
|
function removeListeners() {
|
|
window.removeEventListener('hashchange', updateFromUrl);
|
|
reroll.removeEventListener('click', runReroll);
|
|
swap.removeEventListener('click', runSwap);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => void setup());
|