@dvcol/neo-svelte
Version:
Neomorphic ui library for svelte 5
289 lines (288 loc) • 9.16 kB
JavaScript
function useAbortPromise({ controller = new AbortController(), fallback, error, onAbort, onResolve, onReject, } = {}) {
const { promise, resolve, reject } = Promise.withResolvers();
const abort = () => {
onAbort?.(controller);
if (error)
reject(error);
resolve(fallback);
};
controller.signal.addEventListener('abort', abort);
return {
controller,
promise,
resolve: (value) => {
onResolve?.(value);
controller.signal.removeEventListener('abort', abort);
resolve(value);
},
reject: (reason) => {
onReject?.(reason);
controller.signal.removeEventListener('abort', abort);
reject(reason);
},
abort,
};
}
export const KeyboardLayouts = Object.freeze({
Qwerty: 'qwerty',
Azerty: 'azerty',
});
const KeyboardTypoMap = Object.freeze({
[KeyboardLayouts.Qwerty]: Object.freeze({
'q': 'wa',
'w': 'qse',
'e': 'wrd',
'r': 'etf',
't': 'ryg',
'y': 'tuh',
'u': 'yij',
'i': 'uok',
'o': 'ipl',
'p': 'o',
'a': 'qsz',
's': 'awed',
'd': 'serf',
'f': 'drtg',
'g': 'ftyh',
'h': 'gyuj',
'j': 'huik',
'k': 'jiol',
'l': 'kop',
'z': 'asx',
'x': 'zdc',
'c': 'xdfv',
'v': 'cfgb',
'b': 'vghn',
'n': 'bhjm',
'm': 'njk',
'1': '2q',
'2': '13w',
'3': '24e',
'4': '35r',
'5': '46t',
'6': '57y',
'7': '68u',
'8': '79i',
'9': '80o',
'0': '9p',
'-': '0=',
'=': '-',
'.': ',/',
',': 'm.',
'/': '.',
}),
[KeyboardLayouts.Azerty]: Object.freeze({
'a': 'qzs',
'z': 'aqse',
'e': 'zrsd',
'r': 'etfd',
't': 'rygf',
'y': 'tuhg',
'u': 'yijh',
'i': 'uokj',
'o': 'iplk',
'p': 'o',
'q': 'azw',
's': 'awed',
'd': 'serf',
'f': 'drtg',
'g': 'ftyh',
'h': 'gyuj',
'j': 'huik',
'k': 'jiol',
'l': 'kopm',
'm': 'l',
'w': 'qsx',
'x': 'wcd',
'c': 'xdfv',
'v': 'cfgb',
'b': 'vghn',
'n': 'bhjm',
'1': '2a',
'2': '13z',
'3': '24e',
'4': '35r',
'5': '46t',
'6': '57y',
'7': '68u',
'8': '79i',
'9': '80o',
'0': '9p',
'-': '0=',
'=': '-',
'.': ',/',
',': 'm.',
'/': '.',
}),
});
function generateTypo(char, layout) {
const lowerChar = char.toLowerCase();
const keyMap = KeyboardTypoMap[layout] || KeyboardTypoMap.qwerty;
// If no mapping, return the original character
if (!keyMap[lowerChar])
return char;
const possibleChars = keyMap[lowerChar];
return possibleChars[Math.floor(Math.random() * possibleChars.length)];
}
const spaceRegex = /\s/;
const pauseRegex = /[\s.,;:!?]/;
const defaults = {
typo: {
odds: 0.1,
modulo: 6,
layout: KeyboardLayouts.Qwerty,
},
pause: {
odds: 0.1,
modulo: 1,
speed: {
write: 600,
delete: 400,
},
regex: pauseRegex,
iterations: 2000,
},
speed: {
write: 120,
delete: 80,
},
};
async function sleep(ms = defaults.speed.write, onAbort, controller) {
let timeout;
const { promise, resolve } = useAbortPromise({
controller,
onAbort: (c) => {
clearTimeout(timeout);
onAbort?.(c);
},
});
const speed = Array.isArray(ms) ? ms[Math.floor(Math.random() * ms.length)] : ms;
timeout = setTimeout(resolve, speed);
return promise;
}
const isLine = (line) => typeof line !== 'string';
const toLine = (line) => (isLine(line) ? line : { text: line });
function toRandom(typo) {
if (typeof typo === 'object')
return { enabled: true, ...typo };
if (typo === undefined)
return;
return { enabled: typo };
}
function toSpeed(speed) {
if (typeof speed === 'number')
return { write: speed, delete: speed };
return speed;
}
function mergeOptions(lineOptions, options) {
const pause = { ...defaults.pause, ...toRandom(options.pause), ...toRandom(lineOptions.pause) };
return {
...options,
...lineOptions,
mode: lineOptions.mode || options.mode,
typo: { ...toRandom(options.typo), ...toRandom(lineOptions.typo) },
pause: { ...pause, speed: { ...defaults.pause.speed, ...pause.speed } },
speed: { ...defaults.speed, ...toSpeed(options.speed), ...toSpeed(lineOptions.speed) },
};
}
async function doPause({ display = '', text, index, line, mode = 'write', speed, regex = pauseRegex, odds = defaults.pause.odds, modulo = defaults.pause.modulo, controller, onPause, onAbort, }) {
if (Math.random() >= odds)
return;
const char = text.charAt(index);
if (regex && !regex.test(char))
return;
if (modulo && index % modulo !== 0)
return;
onPause?.({ line, text, display, mode });
await sleep(speed?.[mode], onAbort, controller);
}
async function doTypo({ display = '', text, index, line, speed, mode = 'write', odds = defaults.typo.odds, modulo = defaults.typo.modulo, layout = defaults.typo.layout, onType, onTypo, onAbort, controller, }) {
if (Math.random() >= odds)
return;
if (index % modulo !== 0)
return;
const char = text.charAt(index);
if (spaceRegex.test(char))
return;
// Add typo
display += generateTypo(char, layout);
onType?.({ line, text, display, mode });
onTypo?.({ line, text, display, mode });
await sleep(speed?.write, onAbort, controller);
// Finish word or 5 char
let cursor = index + 1;
let rest = text.charAt(cursor);
while (!spaceRegex.test(rest) && cursor < text.length && cursor < index + modulo * 2) {
if (controller?.signal.aborted)
break;
display += rest;
cursor += 1;
rest = text.charAt(cursor);
onType?.({ line, text, display, mode });
await sleep(speed?.write, onAbort, controller);
}
// Pause after word / 5 char
await sleep(speed?.write, onAbort, controller);
// Remove until typo
while (cursor > index) {
if (controller?.signal.aborted)
return;
display = display.slice(0, -1);
cursor -= 1;
onType?.({ line, text, display, mode });
await sleep(speed?.delete ?? defaults.speed.delete, onAbort, controller);
}
}
async function sliceText(text, line, iteration, mode = 'write', { display = '', controller, typo, pause, speed, onStart, onType, onTypo, onPause, onEnd, onAbort }) {
if (mode === 'delete')
display = text;
// Type text
onStart?.({ display, line, text, iteration, mode });
for (let index = 0; index <= text.length; index += 1) {
// generate typo every modulo 5 with 1/3 chance
if (mode === 'write' && typo?.enabled) {
await doTypo({ display, text, index, line, mode, speed, controller, onType, onTypo, ...typo });
}
if (controller?.signal.aborted)
break;
if (mode === 'write')
display += text.charAt(index);
else
display = display.slice(0, -1);
onType?.({ line, text, display, mode });
await sleep(speed?.[mode] ?? defaults.speed[mode], onAbort, controller);
if (pause?.enabled)
await doPause({ display, text, index, line, mode, controller, onPause, ...pause });
}
onEnd?.({ display, line, text, iteration, mode });
if (pause?.enabled && pause.iterations) {
onPause?.({ line, text, display, mode });
await sleep(typeof pause.iterations === 'number' ? pause.iterations : defaults.pause.iterations, onAbort, controller);
}
}
export async function typewriter({ lines, iterations = 1, controller, ...options }) {
if (!lines.length)
return options.display;
// Iterate over lines
// eslint-disable-next-line no-unmodified-loop-condition -- loop is infinite if iterations is 0
for (let iteration = 0; iterations === 0 || iteration < iterations; iteration += 1) {
if (controller?.signal.aborted)
break;
// Each Line
for (let index = 0; index < lines.length; index += 1) {
if (controller?.signal.aborted)
break;
const line = toLine(lines[index]);
const opts = mergeOptions(line, { controller, ...options });
console.info('Typewriter:', opts);
if (opts.mode === 'loop') {
await sliceText(line.text, index, iteration, 'write', opts);
await sliceText(line.text, index, iteration, 'delete', opts);
}
else {
await sliceText(line.text, index, iteration, opts.mode, opts);
}
}
}
return options.display;
}