UNPKG

@dvcol/neo-svelte

Version:

Neomorphic ui library for svelte 5

289 lines (288 loc) 9.16 kB
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; }