Files
WRNexusJS/packages/reactive/src/advanced.ts
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

217 lines
6.8 KiB
TypeScript

import { signal, type Signal } from "./signal.ts";
export interface HistorySignal<T> extends Signal<T> {
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
clearHistory(): void;
}
export function historySignal<T>(
initial: T,
options: { limit?: number; equals?: (left: T, right: T) => boolean } = {},
): HistorySignal<T> {
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<T> {
url?: URL;
parameter: string;
parse?: (value: string | null) => T;
serialize?: (value: T) => string | null;
replace?: (url: URL) => void;
}
export function urlSignal<T>(initial: T, options: UrlStateOptions<T>): Signal<T> {
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<T> {
provide<R>(value: T, run: () => R): R;
use(): T;
}
export function createContextProvider<T>(defaultValue?: T): ReactiveContext<T> {
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<void> {
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<void>((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<void>;
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<void>((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<void>((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;
}