first commit
This commit is contained in:
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "ladder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "other libraries provide you with a whole framework - this is just a ladder",
|
||||||
|
"scripts": { },
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.djledda.de/djledda/ladder"
|
||||||
|
},
|
||||||
|
"author": "Daniel Ledda <dan.j.ledda@gmail.com>",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
91
src/Publisher.ts
Normal file
91
src/Publisher.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import ISubscriber, {LEvent} from "./Subscriber";
|
||||||
|
|
||||||
|
class PublisherSubscription<EventType extends LEvent> implements ISubscription {
|
||||||
|
private subscriber: ISubscriber<EventType>;
|
||||||
|
private readonly eventTypes: EventType[];
|
||||||
|
private unbindCallback?: () => void;
|
||||||
|
|
||||||
|
constructor(subscriber: ISubscriber<EventType>, eventTypes: EventType[]) {
|
||||||
|
this.subscriber = subscriber;
|
||||||
|
this.eventTypes = eventTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnbind(unbind: () => void): void {
|
||||||
|
this.unbindCallback = unbind;
|
||||||
|
}
|
||||||
|
|
||||||
|
unbind(): void {
|
||||||
|
this.unbindCallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventTypes(): EventType[] {
|
||||||
|
return this.eventTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriber(): ISubscriber<EventType> {
|
||||||
|
return this.subscriber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventSubscriberRecord<T extends LEvent> {
|
||||||
|
get<K extends T>(key: K): ISubscriber<K>[];
|
||||||
|
set<K extends T>(key: K, subscribers: ISubscriber<K>[]): EventSubscriberRecord<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class Publisher<EventType extends LEvent, PublisherType> implements IPublisher<EventType> {
|
||||||
|
private subscribers: EventSubscriberRecord<EventType>;
|
||||||
|
private parent: PublisherType;
|
||||||
|
|
||||||
|
constructor(parent: PublisherType) {
|
||||||
|
this.parent = parent;
|
||||||
|
this.subscribers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubscriber(subscriber: ISubscriber<EventType>, subscribeTo: EventType | Readonly<EventType[]>): ISubscription {
|
||||||
|
let eventTypes: EventType[] = [];
|
||||||
|
if (typeof subscribeTo === "string") {
|
||||||
|
eventTypes.push(subscribeTo);
|
||||||
|
} else {
|
||||||
|
eventTypes = subscribeTo.slice();
|
||||||
|
}
|
||||||
|
for (const key of eventTypes) {
|
||||||
|
this.getSubscribers(key).push(subscriber);
|
||||||
|
}
|
||||||
|
const sub = new PublisherSubscription(subscriber, eventTypes);
|
||||||
|
sub.setUnbind(() => this.unbind(sub));
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unbind(subscription: PublisherSubscription<EventType>): void {
|
||||||
|
for (const key of subscription.getEventTypes()) {
|
||||||
|
const subs = this.getSubscribers(key);
|
||||||
|
subs.splice(subs.indexOf(subscription.getSubscriber()), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubscribers<K extends EventType>(key: K): ISubscriber<K>[] {
|
||||||
|
const subscribersList = this.subscribers.get(key);
|
||||||
|
if (subscribersList === undefined) {
|
||||||
|
const newList: ISubscriber<K>[] = [];
|
||||||
|
this.subscribers.set(key, newList);
|
||||||
|
return newList;
|
||||||
|
} else {
|
||||||
|
return subscribersList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySubs<K extends EventType>(eventType: K): void {
|
||||||
|
for (const sub of this.getSubscribers(eventType)) {
|
||||||
|
sub.notify(this.parent, eventType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPublisher<T extends LEvent> {
|
||||||
|
addSubscriber(subscriber: ISubscriber<T>, subscribeTo: T | T[]): {unbind: () => void};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISubscription {
|
||||||
|
unbind(): void;
|
||||||
|
}
|
||||||
99
src/Ref.ts
Normal file
99
src/Ref.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { ISubscription } from "./Publisher";
|
||||||
|
|
||||||
|
export type MaybeRef<T> = T | Ref<T>;
|
||||||
|
|
||||||
|
class RefSubscription implements ISubscription {
|
||||||
|
private unbindCallback?: () => void;
|
||||||
|
|
||||||
|
constructor(unbindCallback: () => void) {
|
||||||
|
this.unbindCallback = unbindCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
unbind(): void {
|
||||||
|
this.unbindCallback?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stringable {
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllowedRef = { toString(): string } | string | null;
|
||||||
|
|
||||||
|
export default class Ref<T extends AllowedRef = Stringable> {
|
||||||
|
private watchers: Array<(newVal: T) => void> | null = null;
|
||||||
|
private afterWatchers: Array<(newVal: T) => void> | null = null;
|
||||||
|
private value: T;
|
||||||
|
private asString?: string;
|
||||||
|
private isString: boolean;
|
||||||
|
|
||||||
|
private constructor(val: T) {
|
||||||
|
this.value = val;
|
||||||
|
this.isString = typeof val === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
static new<T extends AllowedRef>(val: MaybeRef<T>): Ref<T> {
|
||||||
|
if (val instanceof Ref) {
|
||||||
|
return val;
|
||||||
|
} else {
|
||||||
|
return new Ref<T>(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(watcher: (newVal: T) => void, after?: boolean): ISubscription {
|
||||||
|
if (after) {
|
||||||
|
if (this.afterWatchers === null) {
|
||||||
|
this.afterWatchers = [];
|
||||||
|
}
|
||||||
|
this.afterWatchers.push(watcher);
|
||||||
|
} else {
|
||||||
|
if (this.watchers === null) {
|
||||||
|
this.watchers = [];
|
||||||
|
}
|
||||||
|
this.watchers.push(watcher);
|
||||||
|
}
|
||||||
|
return new RefSubscription(() => this.unbind(watcher, !!after));
|
||||||
|
}
|
||||||
|
|
||||||
|
private unbind(watcher: (newVal: T) => void, after: boolean): void {
|
||||||
|
if (after) {
|
||||||
|
if (!this.afterWatchers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = this.afterWatchers.indexOf(watcher);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.afterWatchers.splice(index, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.watchers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = this.watchers.indexOf(watcher);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.watchers.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get val(): T {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set val(val: T) {
|
||||||
|
this.watchers?.forEach(watcher => watcher(val));
|
||||||
|
this.value = val;
|
||||||
|
this.afterWatchers?.forEach(watcher => watcher(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
if (!this.asString) {
|
||||||
|
if (this.isString) {
|
||||||
|
return this.val as unknown as string;
|
||||||
|
} else {
|
||||||
|
this.asString = this.val?.toString() ?? "null";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.asString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
119
src/Rung.ts
Normal file
119
src/Rung.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Ref from "./Ref";
|
||||||
|
import { ISubscription } from "./Publisher";
|
||||||
|
|
||||||
|
export type RungOptions = {};
|
||||||
|
|
||||||
|
type IRenderAttributes<T extends keyof HTMLElementTagNameMap> = Partial<{
|
||||||
|
[K in keyof HTMLElementTagNameMap[T]]: HTMLElementTagNameMap[T][K] | Ref<HTMLElementTagNameMap[T][K]>
|
||||||
|
}> & {
|
||||||
|
classes?: string[],
|
||||||
|
saveTo?: Ref<HTMLElementTagNameMap[T] | null>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default abstract class Rung {
|
||||||
|
protected el: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(options: RungOptions) {}
|
||||||
|
|
||||||
|
render(): HTMLElement {
|
||||||
|
if (!this.el) {
|
||||||
|
this.el = this.build();
|
||||||
|
}
|
||||||
|
return this.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getEl(): HTMLElement {
|
||||||
|
if (!this.el) {
|
||||||
|
return this.render();
|
||||||
|
} else {
|
||||||
|
return this.el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw(): void {
|
||||||
|
const oldNode = this.el;
|
||||||
|
if (!oldNode || !this.el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = this.el.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
this.el = this.build();
|
||||||
|
parent.replaceChild(this.el, oldNode);
|
||||||
|
} else {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract build(): HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frag(subs?: Rung[]): DocumentFragment {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (subs) {
|
||||||
|
attachSubs(frag, subs);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function q(text: string): Text {
|
||||||
|
return document.createTextNode(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function h<T extends keyof HTMLElementTagNameMap>(type: T, attributes?: IRenderAttributes<T>, subNodes?: (Rung | Node | Ref)[]): HTMLElementTagNameMap[T] {
|
||||||
|
const element = document.createElement(type);
|
||||||
|
if (attributes) {
|
||||||
|
if (attributes.classes) {
|
||||||
|
element.classList.add(...attributes.classes);
|
||||||
|
}
|
||||||
|
if (attributes.saveTo) {
|
||||||
|
attributes.saveTo.val = element;
|
||||||
|
}
|
||||||
|
applyAttributes(element, attributes);
|
||||||
|
}
|
||||||
|
if (subNodes) {
|
||||||
|
attachSubs(element, subNodes);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeRefWatcher<T>(newVal: T extends Ref<infer U> ? U : never, textNode: Text, sub: ISubscription): void {
|
||||||
|
if (!textNode.parentNode) {
|
||||||
|
sub.unbind();
|
||||||
|
textNode.remove();
|
||||||
|
} else {
|
||||||
|
textNode.replaceWith(newVal?.toString() ?? "<dead ref>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSubs(node: Element | DocumentFragment, subNodes: (Rung | Node | Ref)[]): void {
|
||||||
|
for (let i = 0; i < subNodes.length; i++) {
|
||||||
|
const subNode = subNodes[i];
|
||||||
|
if (subNode instanceof Rung) {
|
||||||
|
node.append(subNode.render());
|
||||||
|
} else if (subNode instanceof Ref) {
|
||||||
|
const textNode = q(subNode.val.toString());
|
||||||
|
const sub = subNode.watch((newVal) => nodeRefWatcher<Ref>(newVal, textNode, sub));
|
||||||
|
node.append(textNode);
|
||||||
|
} else {
|
||||||
|
node.append(subNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAttributes<T extends keyof HTMLElementTagNameMap>(element: HTMLElement, attributes: IRenderAttributes<T>): void {
|
||||||
|
for (const key in attributes) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(attributes, key)) {
|
||||||
|
const attribute = (attributes as Record<string, unknown>)[key];
|
||||||
|
if (attribute) {
|
||||||
|
if (attribute instanceof Ref) {
|
||||||
|
const attributeAsRef = attribute as Ref;
|
||||||
|
const elementWithAttributeKey = element as unknown as Record<string, typeof attributeAsRef.val>;
|
||||||
|
elementWithAttributeKey[key] = attributeAsRef.val;
|
||||||
|
attribute.watch((newVal) => elementWithAttributeKey[key] = newVal);
|
||||||
|
} else {
|
||||||
|
(element as unknown as ({ [key: string]: typeof attribute }))[key] = attribute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/Subscriber.ts
Normal file
4
src/Subscriber.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type LEvent = string;
|
||||||
|
export default interface ISubscriber<T extends LEvent> {
|
||||||
|
notify(publisher: unknown, event: T): void;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user