commit b3ee2af7c595b9e0b901423893e11b055e80b0ce Author: Daniel Ledda Date: Sun May 22 22:09:36 2022 +0200 first commit diff --git a/package.json b/package.json new file mode 100644 index 0000000..61992fa --- /dev/null +++ b/package.json @@ -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 ", + "license": "MIT" +} diff --git a/src/Publisher.ts b/src/Publisher.ts new file mode 100644 index 0000000..f5412b0 --- /dev/null +++ b/src/Publisher.ts @@ -0,0 +1,91 @@ +import ISubscriber, {LEvent} from "./Subscriber"; + +class PublisherSubscription implements ISubscription { + private subscriber: ISubscriber; + private readonly eventTypes: EventType[]; + private unbindCallback?: () => void; + + constructor(subscriber: ISubscriber, 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 { + return this.subscriber; + } +} + +interface EventSubscriberRecord { + get(key: K): ISubscriber[]; + set(key: K, subscribers: ISubscriber[]): EventSubscriberRecord; +} + + +export class Publisher implements IPublisher { + private subscribers: EventSubscriberRecord; + private parent: PublisherType; + + constructor(parent: PublisherType) { + this.parent = parent; + this.subscribers = new Map(); + } + + addSubscriber(subscriber: ISubscriber, subscribeTo: EventType | Readonly): 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): void { + for (const key of subscription.getEventTypes()) { + const subs = this.getSubscribers(key); + subs.splice(subs.indexOf(subscription.getSubscriber()), 1); + } + } + + private getSubscribers(key: K): ISubscriber[] { + const subscribersList = this.subscribers.get(key); + if (subscribersList === undefined) { + const newList: ISubscriber[] = []; + this.subscribers.set(key, newList); + return newList; + } else { + return subscribersList; + } + } + + notifySubs(eventType: K): void { + for (const sub of this.getSubscribers(eventType)) { + sub.notify(this.parent, eventType); + } + } +} + +export interface IPublisher { + addSubscriber(subscriber: ISubscriber, subscribeTo: T | T[]): {unbind: () => void}; +} + +export interface ISubscription { + unbind(): void; +} \ No newline at end of file diff --git a/src/Ref.ts b/src/Ref.ts new file mode 100644 index 0000000..b1306d7 --- /dev/null +++ b/src/Ref.ts @@ -0,0 +1,99 @@ +import { ISubscription } from "./Publisher"; + +export type MaybeRef = T | Ref; + +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 { + 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(val: MaybeRef): Ref { + if (val instanceof Ref) { + return val; + } else { + return new Ref(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; + } +} + diff --git a/src/Rung.ts b/src/Rung.ts new file mode 100644 index 0000000..17514e4 --- /dev/null +++ b/src/Rung.ts @@ -0,0 +1,119 @@ +import Ref from "./Ref"; +import { ISubscription } from "./Publisher"; + +export type RungOptions = {}; + +type IRenderAttributes = Partial<{ + [K in keyof HTMLElementTagNameMap[T]]: HTMLElementTagNameMap[T][K] | Ref +}> & { + classes?: string[], + saveTo?: Ref, +}; + +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(type: T, attributes?: IRenderAttributes, 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(newVal: T extends Ref ? U : never, textNode: Text, sub: ISubscription): void { + if (!textNode.parentNode) { + sub.unbind(); + textNode.remove(); + } else { + textNode.replaceWith(newVal?.toString() ?? ""); + } +} + +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(newVal, textNode, sub)); + node.append(textNode); + } else { + node.append(subNode); + } + } +} + +function applyAttributes(element: HTMLElement, attributes: IRenderAttributes): void { + for (const key in attributes) { + if (Object.prototype.hasOwnProperty.call(attributes, key)) { + const attribute = (attributes as Record)[key]; + if (attribute) { + if (attribute instanceof Ref) { + const attributeAsRef = attribute as Ref; + const elementWithAttributeKey = element as unknown as Record; + elementWithAttributeKey[key] = attributeAsRef.val; + attribute.watch((newVal) => elementWithAttributeKey[key] = newVal); + } else { + (element as unknown as ({ [key: string]: typeof attribute }))[key] = attribute; + } + } + } + } +} diff --git a/src/Subscriber.ts b/src/Subscriber.ts new file mode 100644 index 0000000..35afd2b --- /dev/null +++ b/src/Subscriber.ts @@ -0,0 +1,4 @@ +export type LEvent = string; +export default interface ISubscriber { + notify(publisher: unknown, event: T): void; +} \ No newline at end of file