217 lines
6.8 KiB
TypeScript
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;
|
|
}
|