import { signal, type Signal } from "./signal.ts"; export interface HistorySignal extends Signal { undo(): boolean; redo(): boolean; canUndo(): boolean; canRedo(): boolean; clearHistory(): void; } export function historySignal( initial: T, options: { limit?: number; equals?: (left: T, right: T) => boolean } = {}, ): HistorySignal { const limit = options.limit ?? 100; if (!Number.isInteger(limit) || limit < 1) throw new RangeError("history limit must be positive"); const current = signal(initial); const past: T[] = []; const future: T[] = []; const equals = options.equals ?? Object.is; const set = current.set.bind(current); current.set = (value) => { const next = typeof value === "function" ? (value as (previous: T) => T)(current.get()) : value; const previous = current.get(); if (equals(previous, next)) return; past.push(previous); if (past.length > limit) past.shift(); future.length = 0; set(next); }; return Object.assign(current, { undo() { const value = past.pop(); if (value === undefined) return false; future.push(current.get()); set(value); return true; }, redo() { const value = future.pop(); if (value === undefined) return false; past.push(current.get()); set(value); return true; }, canUndo: () => past.length > 0, canRedo: () => future.length > 0, clearHistory() { past.length = 0; future.length = 0; }, }); } export interface UrlStateOptions { url?: URL; parameter: string; parse?: (value: string | null) => T; serialize?: (value: T) => string | null; replace?: (url: URL) => void; } export function urlSignal(initial: T, options: UrlStateOptions): Signal { const url = options.url ?? new URL(globalThis.location?.href ?? "http://localhost/"); const parsed = options.parse?.(url.searchParams.get(options.parameter)); const state = signal(parsed ?? initial); const original = state.set.bind(state); state.set = (value) => { original(value); const serialized = options.serialize ? options.serialize(state.get()) : String(state.get()); if (serialized === null) url.searchParams.delete(options.parameter); else url.searchParams.set(options.parameter, serialized); if (options.replace) options.replace(new URL(url)); else globalThis.history?.replaceState?.(null, "", url); }; return state; } export interface ReactiveContext { provide(value: T, run: () => R): R; use(): T; } export function createContextProvider(defaultValue?: T): ReactiveContext { const stack: T[] = []; return { provide(value, run) { stack.push(value); try { return run(); } finally { stack.pop(); } }, use() { if (stack.length) return stack[stack.length - 1]!; if (defaultValue !== undefined) return defaultValue; throw new Error("WRN-REACTIVE-CONTEXT: no provider is active."); }, }; } export function mountPortal(content: Node | string, target: Element): () => void { const marker = target.ownerDocument.createComment("wrnexus-portal"); const node = typeof content === "string" ? target.ownerDocument.createTextNode(content) : content; target.append(marker, node); return () => { marker.remove(); node.parentNode?.removeChild(node); }; } export async function transition( update: () => void, options: { className?: string; target?: Element; durationMs?: number; signal?: AbortSignal } = {}, ): Promise { if (options.signal?.aborted) throw options.signal.reason; const target = options.target; const className = options.className ?? "wrn-transition"; target?.classList.add(className); update(); const duration = options.durationMs ?? 0; if (duration > 0) await new Promise((resolve, reject) => { const timer = setTimeout(resolve, duration); options.signal?.addEventListener( "abort", () => { clearTimeout(timer); reject(options.signal!.reason); }, { once: true }, ); }); target?.classList.remove(className); } export interface TimelineStep { durationMs: number; delayMs?: number; easing?: (progress: number) => number; update(progress: number): void; } export interface AnimationTimeline { play(options?: { reverse?: boolean; signal?: AbortSignal }): Promise; cancel(reason?: unknown): void; readonly running: boolean; } export function createTimeline( steps: TimelineStep[], options: { now?: () => number; frame?: (callback: () => void) => unknown } = {}, ): AnimationTimeline { for (const step of steps) { if ( !Number.isFinite(step.durationMs) || step.durationMs < 0 || !Number.isFinite(step.delayMs ?? 0) || (step.delayMs ?? 0) < 0 ) throw new RangeError("Timeline durations and delays must be non-negative"); } const now = options.now ?? (() => performance.now()); const frame = options.frame ?? ((callback) => requestAnimationFrame(callback)); let controller: AbortController | null = null; const timeline: AnimationTimeline = { get running() { return controller !== null; }, cancel(reason = new Error("Timeline cancelled")) { controller?.abort(reason); }, async play(playOptions = {}) { controller?.abort(new Error("Timeline restarted")); controller = new AbortController(); const local = controller; playOptions.signal?.addEventListener("abort", () => local.abort(playOptions.signal!.reason), { once: true, }); const ordered = playOptions.reverse ? [...steps].reverse() : steps; try { for (const step of ordered) { const delay = step.delayMs ?? 0; if (delay) await new Promise((resolve, reject) => { const timer = setTimeout(resolve, delay); local.signal.addEventListener( "abort", () => { clearTimeout(timer); reject(local.signal.reason); }, { once: true }, ); }); const started = now(); await new Promise((resolve, reject) => { const tick = () => { if (local.signal.aborted) { reject(local.signal.reason); return; } const raw = step.durationMs === 0 ? 1 : Math.min(1, (now() - started) / step.durationMs); const progress = playOptions.reverse ? 1 - raw : raw; step.update(step.easing?.(progress) ?? progress); if (raw >= 1) resolve(); else frame(tick); }; tick(); }); } } finally { if (controller === local) controller = null; } }, }; return timeline; }