Skip to content

Instantly share code, notes, and snippets.

@Skateside
Last active January 26, 2024 22:58
Show Gist options
  • Save Skateside/48f467940cfab0525b262136e8928c49 to your computer and use it in GitHub Desktop.
Save Skateside/48f467940cfab0525b262136e8928c49 to your computer and use it in GitHub Desktop.
Just thinking aloud about re-writing the Pocket Grimoire
export {}
declare global {
interface ObjectConstructor {
/**
* Checks to see if the given property is in the given object.
* @param object Object to check in.
* @param property Property to check for.
*/
hasOwn(
object: Record<PropertyKey, any>,
property: PropertyKey
): boolean;
/**
* Groups the given objects by a key taken from the object values.
* @param items Items to group.
* @param callbackFn Function identifying the key for the groups.
*/
groupBy<T, K extends PropertyKey>(
items: T[],
callbackFn: (element: T, index: number) => K
): Record<K, T[]>;
}
}
import "./lib.object-hasown-groupby";
// ========================================================================== //
type IJinx_demo = {
id: string,
reason: string,
state: "theoretical" | "potential" | "active",
};
// state: "theoretical" = this jinx exists but only the role is in the script,
// the id mentioned here isn't.
// state: "potential" = the role and the id are both in the script, but they not
// both in play.
// state: "active" = both the role and id are in play.
type ITeam_demo = "townsfolk" | "outsider" | "minion" | "demon" | "traveller" | "fabled";
type IRole_demo = {
id: string,
team: ITeam_demo,
name: string,
image: string,
firstNight: number,
firstNightReminder: string,
otherNight: number,
otherNightReminder: string,
setup: boolean,
reminders: string[],
remindersGlobal?: string[],
jinxes?: IJinx_demo[],
// ...
};
type IData_demo = {
role: IRole_demo,
origin: "official" | "homebrew" | "augment",
inScript: boolean,
inPlay: number,
augment?: Partial<IRole_demo>,
// coordinates?: ICoordinates_demo[], // NOTE: this wouldn't extend to reminder tokens.
};
// origin: "official" = this role came from the database, it's an official role.
// origin: "homebrew" = this role came from the given script.
// origin: "augment" = this role cam from the database but something in the
// script added to it or replaced part of it.
// This object would also have an `augment` property.
type IRepository_demo = IData_demo[];
const repository: IRepository_demo = [
{
role: {
id: "washerwoman",
team: "townsfolk",
name: "Washerwoman",
image: "",
firstNight: 10,
firstNightReminder: "",
otherNight: 0,
otherNightReminder: "",
setup: false,
reminders: []
},
origin: "official",
inScript: false,
inPlay: 0,
}
];
// Find all roles in the script.
// repository.filter(({ inScript }) => inScript);
// Find all roles that have been added to the grimoire.
// repository.filter(({ inPlay }) => inPlay > 0);
// Find "washerwoman" in repository.
// repository.find(({ role }) => objectMatches({ id: "washerwoman" }, role));
function objectMatches(check: Record<string, any> | null, source: Record<string, any> | null): boolean {
if (!check || !source) {
return (!check && !source);
}
return Object.entries(check).every(([key, value]) => source[key] === value);
}
function deepClone<T extends Record<string, any>>(object: T): T {
return JSON.parse(JSON.stringify(object));
}
// Find any travellers, group them by "in script" and not
// const travellers = Object.groupBy(
// repository.filter(({ role: { team }}) => team === "traveller"),
// (data: IData_demo) => data.inScript ? "in" : "out"
// );
// ========================================================================== //
// type ICoordinates_demo = {
// x: number,
// y: number,
// z?: number,
// };
// type IToken_demo = {
// role: IRole_demo,
// coordinates: ICoordinates_demo,
// };
// type ICharacterToken_demo = IToken_demo & {
// name?: string
// };
// type IReminderToken_demo = IToken_demo & {
// index: number,
// isGlobal: boolean
// };
// role.reminders[index] = text
// isGlobal ? role.remindersGlobal[index]
// -------------------------------------------------------------------------- //
// MVC approach 2
function memoise<R, T extends (...args: any[]) => R>(
handler: T,
keymaker = (...args: Parameters<T>) => String(args[0]),
): T {
const cache: Record<string, R> = Object.create(null);
const func = (...args: Parameters<T>) => {
const key = keymaker(...args);
if (!Object.hasOwn(cache, key)) {
cache[key] = handler(...args);
}
return cache[key];
};
return func as T;
}
let identifyCounter = 0;
function identify(element: Element | Document | null, prefix = "anonymous-") {
if (!element) {
return "";
}
if (element === document) {
return "_document_";
}
element = element as HTMLElement;
let {
id,
} = element;
if (!id) {
do {
id = `${prefix}${identifyCounter}`;
identifyCounter += 1;
} while (document.getElementById(id));
element.id = id;
}
return id;
}
type IQuerySelectorOptions_demo = Partial<{
required: boolean,
root: HTMLElement | Document | null
}>;
function querySelector<T extends HTMLElement>(
selector: string,
options: IQuerySelectorOptions_demo = {}
) {
const root = (
Object.hasOwn(options, "root")
? options.root
: document
);
if (!root) {
throw new TypeError("Cannot look up element - root is missing");
}
const element = root.querySelector<T>(selector);
if (options.required && !element) {
throw new ReferenceError(`Cannot find an element matching selector "${selector}"`);
}
// return element as HTMLElement;
return element;
}
const querySelectorCached = memoise(querySelector, (selector, options) => {
return `#${identify(options?.root || null)} ${selector}`;
});
function updateChildren(
content: HTMLElement | DocumentFragment,
updates: Record<string, (element: HTMLElement) => void>
) {
Object.entries(updates).forEach(([selector, updater]) => {
content.querySelectorAll<HTMLElement>(selector).forEach((element) => {
updater(element);
});
});
}
function renderTemplate(
selector: string,
populates: Record<string, (element: HTMLElement) => void>
): DocumentFragment {
const template = querySelectorCached<HTMLTemplateElement>(selector, {
required: true
})!;
const clone = template.content.cloneNode(true) as DocumentFragment;
updateChildren(clone, populates);
return clone;
}
type ObserverHandler<T extends any = any> = (detail: T) => void;
type ObserverConverted = (event: Event) => void;
class Observer<EventMap = {}> {
private observerElement: HTMLElement;
private observerMap: WeakMap<ObserverHandler, ObserverConverted>;
constructor() {
this.observerElement = document.createElement("div");
this.observerMap = new WeakMap();
}
private convertObserverHandler(handler: ObserverHandler): ObserverConverted {
// https://stackoverflow.com/a/65996495/557019
const converted: ObserverConverted = (
({ detail }: CustomEvent) => handler(detail)
) as EventListener;
this.observerMap.set(handler, converted);
return converted;
}
private unconvertObserverHandler(handler: ObserverHandler): ObserverConverted {
const unconverted = this.observerMap.get(handler);
return unconverted || handler;
}
trigger<K extends keyof EventMap>(eventName: K, detail: EventMap[K]) {
this.observerElement
.dispatchEvent(new CustomEvent<EventMap[K]>(eventName as string, {
detail,
bubbles: false,
cancelable: false,
}));
}
on<K extends keyof EventMap>(eventName: K, handler: ObserverHandler<EventMap[K]>) {
this.observerElement.addEventListener(
eventName as string,
this.convertObserverHandler(handler)
);
}
off<K extends keyof EventMap>(eventName: K, handler: ObserverHandler<EventMap[K]>) {
this.observerElement.addEventListener(
eventName as string,
this.unconvertObserverHandler(handler)
);
}
}
class Model<EventMap = {}> extends Observer<EventMap> {
}
type INights_demo<T> = {
[K in 'first' | 'other']: T[]
};
type IRepositoryNights_demo = INights_demo<IData_demo>;
type IRepositoryNightsRoles_demo = INights_demo<IRole_demo>;
class RepositoryModel extends Model<{
"script-update": null,
"inplay-update": null
}> {
protected repository: IRepository_demo = [];
static enwrapRole(role: IRole_demo, options: Partial<IData_demo> = {}): IData_demo {
return {
role,
origin: "official",
inScript: false,
inPlay: 0,
...options
};
}
static makeEmptyRole(): IRole_demo {
return {
id: "",
team: "outsider",
name: "",
image: "#",
firstNight: 0,
firstNightReminder: "",
otherNight: 0,
otherNightReminder: "",
setup: false,
reminders: [],
};
}
static getRoleData(datum: IData_demo): IRole_demo {
const {
role,
origin,
augment,
} = datum;
const data = deepClone(role);
if (origin !== "augment" || !augment) {
return data;
}
const cloneAugment = deepClone(augment);
const jinxes = cloneAugment.jinxes;
delete cloneAugment.jinxes;
Object.assign(data, cloneAugment);
if (jinxes) {
data.jinxes = this.mergeJinxes(data.jinxes || [], jinxes);
}
return data;
}
static mergeJinxes(source: IJinx_demo[], augment: IJinx_demo[]): IJinx_demo[] {
return augment.reduce((merged, { id, reason }) => {
let index = merged.findIndex(({ id: mergedId }) => id === mergedId);
if (index < 0) {
index = merged.length;
}
merged[index] = {
id,
reason,
state: "theoretical"
};
return merged;
}, source);
}
findRoleIndex(search: Partial<IRole_demo>): number {
return this.repository.findIndex(({ role }) => objectMatches(search, role));
}
findRole(search: Partial<IRole_demo>): IData_demo | undefined {
return this.repository[this.findRoleIndex(search)];
}
addOfficialRole(role: IRole_demo) {
const {
repository
} = this;
const constructor = this.constructor as typeof RepositoryModel;
const {
id
} = role;
let index = (
id
? this.findRoleIndex({ id })
: repository.length
);
if (index < 0) {
index = repository.length;
}
repository[index] = constructor.enwrapRole(role);
}
addHomebrewRole(role: Partial<IRole_demo>) {
const {
repository
} = this;
const constructor = this.constructor as typeof RepositoryModel;
const index = this.findRoleIndex({ id: role.id });
// Patch for the American spelling of "traveller".
if ((role as any).team === "traveler") {
role.team = "traveller";
}
if (index < 0) {
repository.push(
constructor.enwrapRole({
...constructor.makeEmptyRole(),
...role
}, {
origin: "homebrew"
})
);
} else {
// NOTE: possible future bug
// If someone uploads the same homebrew character twice, we might
// end up augmenting a homebrew character, causing it to seem
// official if the repository is reset.
repository[index] = {
...repository[index],
...{
origin: "augment",
augment: role
}
};
}
}
getRoles() {
return Object.fromEntries(
this.repository.map(({ role }) => [
role.id, role
])
);
}
resetRepository() {
const {
repository
} = this;
let index = repository.length;
while (index) {
index -= 1;
const data = repository[index];
data.inPlay = 0;
data.inScript = false;
if (data.origin === "augment") {
data.origin = "official";
delete data.augment;
} else if (data.origin === "homebrew") {
repository.splice(index, 1);
}
}
}
setScript(script: (Partial<IRole_demo> & Pick<IRole_demo, "id">)[]) {
this.repository.forEach((data) => {
data.inScript = false;
data.role.jinxes?.forEach((jinx) => jinx.state = "theoretical");
});
const roles: Record<string, IRole_demo> = Object.create(null);
script.forEach(({ id }) => {
const data = this.findRole({ id });
if (!data) {
return;
}
data.inScript = true;
const {
role
} = data;
roles[role.id] = role;
});
const ids = Object.keys(roles);
Object.values(roles).forEach((role) => {
role.jinxes?.forEach((jinx) => {
if (ids.includes(jinx.id)) {
jinx.state = "potential";
}
});
});
}
getScript() {
return this.repository.filter(({ inScript }) => inScript);
}
getScriptRoles() {
const constructor = this.constructor as typeof RepositoryModel;
return this.getScript().map((data) => constructor.getRoleData(data));
}
getInPlay() {
return this.repository.filter(({ inPlay }) => inPlay > 0);
}
getInPlayRoles() {
const constructor = this.constructor as typeof RepositoryModel;
return this.getInPlay().map((data) => constructor.getRoleData(data));
}
getTeam(team: ITeam_demo) {
return Object.groupBy(
repository.filter(({ role }) => role.team === team),
(data: IData_demo) => {
return (
data.inScript
? "in"
: "out"
);
}
);
}
getTeamRoles(team: ITeam_demo) {
const constructor = this.constructor as typeof RepositoryModel;
const inOut = this.getTeam(team);
return {
in: inOut.in.map((data) => constructor.getRoleData(data)),
out: inOut.out.map((data) => constructor.getRoleData(data))
};
}
getNight(script: IData_demo[], type: keyof IRepositoryNights_demo) {
const constructor = this.constructor as typeof RepositoryModel;
const night: Record<string, IData_demo[]> = Object.create(null);
script.forEach((data) => {
const role = constructor.getRoleData(data);
const nightOrder = role[`${type}Night`];
if (nightOrder <= 0) {
return;
}
if (!night[nightOrder]) {
night[nightOrder] = [];
}
nightOrder[nightOrder].push(data);
});
return Object.entries(night)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.reduce((sorted, [ignore, data]) => {
return sorted.concat(...data);
}, [] as IData_demo[]);
}
getScriptNights(): IRepositoryNights_demo {
const script = this.getScript();
return {
first: this.getNight(script, "first"),
other: this.getNight(script, "other")
};
}
getScriptNightsRoles(): IRepositoryNightsRoles_demo {
const constructor = this.constructor as typeof RepositoryModel;
const nights = this.getScriptNights();
return {
first: nights.first.map((data) => constructor.getRoleData(data)),
other: nights.other.map((data) => constructor.getRoleData(data))
};
}
}
// class RoleModel extends Model {}
class View<EventMap = {}> extends Observer<EventMap> {
discoverElements() {
return;
}
addListeners() {
return;
}
ready() {
this.discoverElements();
this.addListeners();
}
}
class NightOrderView extends View<{
}> {
protected firstNight: HTMLElement;
protected otherNights: HTMLElement;
protected showNotInPlay: HTMLInputElement;
discoverElements() {
const options = {
required: true
};
this.firstNight = querySelectorCached("#first-night", options)!;
this.otherNights = querySelectorCached("#other-nights", options)!;
this.showNotInPlay = querySelectorCached<HTMLInputElement>("#show-all", options)!;
}
drawNights({ first, other }: IRepositoryNightsRoles_demo) {
this.firstNight.replaceChildren(
...first.map((role) => this.drawEntry(role, "first"))
);
this.otherNights.replaceChildren(
...other.map((role) => this.drawEntry(role, "other"))
);
}
drawEntry(role: IRole_demo, type: keyof IRepositoryNightsRoles_demo) {
return renderTemplate("#night-order-entry", {
".js--night-order-entry--wrapper"(element) {
element.dataset.id = role.id;
},
".js--night-order-entry--name"(element) {
element.textContent = role.name;
},
".js--night-order-entry--text"(element) {
element.textContent = role[`${type}NightReminder`];
},
".js--night-order-entry--image"(element) {
(element as HTMLImageElement).src = role.image;
},
});
}
static markInPlay(element: HTMLElement, ids: string[]) {
element.classList.toggle(
"is-playing",
ids.includes(element.dataset.id || "")
);
}
markInPlay(roles: IRole_demo[]) {
const constructor = this.constructor as typeof NightOrderView;
const ids = roles.map(({ id }) => id);
this.firstNight
.querySelectorAll<HTMLElement>(".js--night-order-entry--wrapper")
.forEach((element) => {
constructor.markInPlay(element, ids);
});
this.otherNights
.querySelectorAll<HTMLElement>(".js--night-order-entry--wrapper")
.forEach((element) => {
constructor.markInPlay(element, ids);
});
}
addListeners() {
const {
firstNight,
otherNights,
showNotInPlay
} = this;
showNotInPlay.addEventListener("change", () => {
firstNight.classList.toggle("is-show-all", showNotInPlay.checked);
otherNights.classList.toggle("is-show-all", showNotInPlay.checked);
});
}
}
class Controller<M extends Model, V extends View> {
protected model: M;
protected view: V;
constructor(model: M, view: V) {
this.model = model;
this.view = view;
}
render() {
// this.model.ready();
this.view.ready();
}
}
class NightOrderController extends Controller<RepositoryModel, NightOrderView> {
render() {
super.render();
const {
model,
view
} = this;
// view.ready();
view.drawNights(model.getScriptNightsRoles());
view.markInPlay(model.getInPlayRoles());
model.on("script-update", () => {
view.drawNights(model.getScriptNightsRoles());
view.markInPlay(model.getInPlayRoles());
});
model.on("inplay-update", () => {
view.markInPlay(model.getInPlayRoles());
});
}
}
type IColours_demo = "blue" | "dark-orange" | "dark-purple" | "green" | "grey" | "orange" | "purple" | "red";
type IInfoData_demo = {
text: string,
colour: IColours_demo,
type: "official" | "homebrew",
index?: number
}
class InfoModel extends Model<{
"infos-update": null,
"info-update": IInfoData_demo,
"info-remove": number
}> {
protected infos: IInfoData_demo[] = [];
addOfficialInfo(text: string, colour: IColours_demo = "blue") {
this.infos.push({
text,
colour,
type: "official"
});
}
addHomebrewInfo(text: string) {
this.infos.push({
text,
colour: "grey",
type: "homebrew"
});
}
updateInfo(index: number, text: string) {
const {
infos
} = this;
const info = infos[index];
if (!info) {
throw new ReferenceError(`Cannot find info token with index "${index}"`);
}
if (info.type === "homebrew") {
throw new Error("Cannot update an official info token");
}
info.text = text;
}
getInfos() {
return Object.groupBy(
this.infos.map((info, index) => ({ ...info, index })),
({ type }) => type
);
}
resetInfos() {
const {
infos
} = this;
let index = infos.length;
while (index) {
index -= 1;
// Remove anything that's homebrew or that's been deleted.
if (!infos[index] || infos[index].type === "homebrew") {
infos.splice(index, 1);
}
}
}
deleteInfo(index: number) {
delete this.infos[index];
// this will preserve the existing indicies so we don't have to re-draw
// all the buttons/dialogs.
}
}
function markdownToHTML(raw: string): string {
// TODO
return raw;
}
function stripMarkdown(raw: string): string {
// TODO
return raw;
}
class InfoView extends View<{
"info-edit": null,
"info-remove": number
}> {
// protected official: HTMLElement;
protected homebrew: HTMLElement;
protected dialogs: HTMLElement;
protected addButton: HTMLElement;
static makeDialogId(info: IInfoData_demo) {
return `info-token--${info.index}`;
}
discoverElements() {
const options: IQuerySelectorOptions_demo = {
required: true
}
// this.official = querySelectorCached("#info-token-button-holder", options);
this.homebrew = querySelectorCached("#info-token-custom-holder", options)!;
this.dialogs = querySelectorCached("#info-token-dialog-holder", options)!;
this.addButton = querySelectorCached("#add-info-token", options)!;
}
removeHomebrewByIndex(index: number) {
if (Number.isNaN(index)) {
throw new TypeError("NaN given to removeHomebrewByIndex");
}
const {
dialogs,
homebrew
} = this;
dialogs.querySelector(`#info-token--${index}`)?.remove();
homebrew.querySelector(`[data-dialog="#info-token--${index}"]`)
?.closest(".js--info-token--wrapper")
?.remove();
}
drawHomebrew(infos: IInfoData_demo[]) {
infos.forEach((info) => this.drawHomebrewEntry(info));
}
drawHomebrewEntry(info: IInfoData_demo) {
const {
index
} = info;
// Maybe something is needed here to generate the index if it's not yet set.
if (typeof index === "number") {
this.removeHomebrewByIndex(index);
}
const constructor = this.constructor as typeof InfoView;
const dialogId = constructor.makeDialogId(info);
this.dialogs.append(
renderTemplate("#info-token-dialog-template", {
".js--info-token--dialog"(element) {
element.id = dialogId;
element.style.setProperty(
"--colour",
`var(--${info.colour})`
);
},
".js--info-token--actions"(element) {
element.querySelectorAll("button").forEach((button) => {
button.dataset.index = String(info.index);
});
}
})
);
this.homebrew.append(
renderTemplate("#info-token-button-template", {
".js--info-token--wrapper"(element) { // <li>
element.dataset.index = String(info.index);
},
".js--info-token--button"(element) {
element.dataset.dialog = `#${dialogId}`;
}
})
);
this.updateHomebrew(info);
}
updateHomebrew(info: IInfoData_demo) {
const constructor = this.constructor as typeof InfoView;
const dialogId = constructor.makeDialogId(info);
const dialog = this.dialogs.querySelector<HTMLElement>(`#${dialogId}`);
const homebrew = this.homebrew.querySelector<HTMLElement>(
`.js--info-token--wrapper[dataset-index="${info.index}"]`
);
if (!dialog || !homebrew) {
return;
}
updateChildren(dialog, {
".js--info-token--dialog-text"(element) {
element.innerHTML = markdownToHTML(info.text);
}
});
updateChildren(homebrew, {
".js--info-token--button"(element) {
element.textContent = stripMarkdown(info.text);
element.style.setProperty(
"--bg-colour",
`var(--${info.colour})`
);
}
});
}
}
class InfoController extends Controller<InfoModel, InfoView> {
render(): void {
super.render();
const {
model,
view
} = this;
view.drawHomebrew(model.getInfos().homebrew);
view.on("info-edit", () => {
// Trigger something that allows us to edit the info text.
});
view.on("info-remove", (index) => {
model.deleteInfo(index);
});
model.on("infos-update", () => {
view.drawHomebrew(model.getInfos().homebrew);
});
model.on("info-update", (info) => {
view.updateHomebrew(info);
});
model.on("info-remove", (index) => {
view.removeHomebrewByIndex(index);
});
}
}
type ICoordinates_demo = {
x: number,
y: number,
z?: number,
};
type IToken_demo = {
role: IRole_demo,
coords: ICoordinates_demo,
};
type IRoleToken_demo = IToken_demo & {
name?: string,
isDead: boolean,
isUpsideDown: boolean,
};
type IReminderToken_demo = IToken_demo & {
index: number,
};
class TokenModel extends Model {
protected roles: IRoleToken_demo[];
protected reminders: IReminderToken_demo[];
static enwrapRole(role: IRole_demo): IRoleToken_demo {
return {
role,
coords: {
x: 0,
y: 0,
z: 0,
},
name: "",
isDead: false,
isUpsideDown: false,
};
}
static enwrapReminder(role: IRole_demo, index: number): IReminderToken_demo {
return {
role,
index,
coords: {
x: 0,
y: 0,
z: 0,
},
};
}
addRole(role: IRole_demo) {
const constructor = this.constructor as typeof TokenModel;
this.roles.push(constructor.enwrapRole(role));
}
addReminder(role: IRole_demo, index: number) {
const constructor = this.constructor as typeof TokenModel;
this.reminders.push(constructor.enwrapReminder(role, index));
}
getRoles() {
return [...this.roles];
}
getReminders() {
return [...this.reminders];
}
setRoleCoords(index: number, coords: ICoordinates_demo) {
const role = this.roles[index];
if (!role) {
throw new ReferenceError(`Cannot find role with index ${index}`);
}
role.coords = coords;
}
}
class TokenView extends View {
protected zIndex = 0;
updateZIndex(zIndex: number | undefined) {
this.zIndex = Math.max(this.zIndex, zIndex || 0) + 1;
}
drawRole(role: IRole_demo, coordinates: ICoordinates_demo, index: number) {
this.updateZIndex(coordinates.z);
}
drawReminder(role: IRole_demo, coordinates: ICoordinates_demo, index: number) {
this.updateZIndex(coordinates.z);
}
}
class TokenController extends Controller<TokenModel, TokenView> {
protected roles: Record<string, IRole_demo> = Object.create(null);
setRoles(roles: Record<string, IRole_demo>) {
this.roles = roles;
}
render(): void {
const {
model,
view
} = this;
model.getRoles().forEach(({ role, coords }, index) => {
view.drawRole(role, coords, index);
});
model.getReminders().forEach(({ role, coords }, index) => {
view.drawReminder(role, coords, index);
});
}
}
// -------------------------------------------------------------------------- //
const repositoryModel = new RepositoryModel();
const infoModel = new InfoModel();
const tokenModel = new TokenModel();
const nightOrderView = new NightOrderView();
const infoView = new InfoView();
const tokenView = new TokenView();
const nightOrderController = new NightOrderController(repositoryModel, nightOrderView);
const infoController = new InfoController(infoModel, infoView);
const tokenController = new TokenController(tokenModel, tokenView);
tokenController.setRoles(repositoryModel.getRoles());
// ========================================================================== //
// Thinking of an MVC architecture
// https://blog.logrocket.com/node-js-project-architecture-best-practices/
// Attempt 1: possibly too fragmented, too much syntax
@netcall-jlo
Copy link

You can simplify IRepositoryNights_demo a little

type IRepositoryNights_demo = {
	[K in 'first' | 'other']: IData_demo[];
};
type IRepositoryNightsRoles_demo = {
	[K in keyof IRepositoryNights_demo]: IRole_demo[];
};

or you could use generics

type INights_demo<T> = {
	[K in 'first' | 'other']: T[]
};
type IRepositoryNights_demo = INights<IData_demo>;
type IRepositoryNightsRoles_demo = INights<IRole_demo>;

You can also refer to them in RepositoryModel#getNights and NightOrderView#drawEntry

class NightOrderView extends View {
	drawEntry(role: IRole_demo, type: keyof IRepositoryNights_demo) {
		// ...
	}
}

@netcall-jlo
Copy link

Small improvement to some utility functions

function objectMatches(
	check: Record<PropertyKey, any> | null,
	source: Record<PropertyKey, any> | null,
) {

	return (
		check === source
		|| Boolean(
			check
			&& source
			&& Object.entries(check).every(([key, value]) => {
				return Object.is(source[key], value);
			})
		)
	);

}

// Although ... what are the odds of `objectMatches` being given `null`?

function objectMatches(
	check: Record<PropertyKey, any>,
	source: Record<PropertyKey, any>,
) {

	return Object
		.entries(check)
		.every(([key, value]) => Object.is(source[key], value));

}

You should tweak the querySelectorCached keymaker function and then tweak identify so it only gets given an HTMLElement - ignore document and null.

const querySelectorCached = memoise(querySelector, (selector, options) => {

	const root = options?.root;
	let id = '';

	if (root === document) {
		id = 'Document';
	} else if (root) {
		id = `#${identify(root)}`;
	}

	return `${id} ${selector}`;

});

@netcall-jlo
Copy link

You should update the renderTemplate function so that we can update elements with another function.

function updateChildren(
	content: HTMLElement | DocumentFragment,
	updates: Record<string, (element: HTMLElement) => void>
) {

	Object.entries(updates).forEach(([selector, updater]) => {

		content.querySelectorAll<HTMLElement>(selector).forEach((element) => {
			updater(element);
		});

	});

}

function renderTemplate(
	selector: string,
	populates: Record<string, (element: HTMLElement) => void>
): DocumentFragment {

	const template = querySelectorCached<HTMLTemplateElement>(selector, {
		required: true
	})!;
	const clone = template.content.cloneNode(true) as DocumentFragment;

	updateChildren(clone, populates);

	return clone;

}

This would allow InfoView to be updated.

class InfoView {

	// ...

	static makeDialogId(info: IInfoData_demo) {
		return `info-token--${info.index}`;
	}

	drawHomebrew(infos: IInfoData_demo[]) {
		infos.forEach((info) => this.drawHomebrewEntry(info));
	}

	drawHomebrewEntry(info: IInfoData_demo) {

		const {
			index
		} = info;

		// Maybe something is needed here to generate the index if it's not yet set.
		if (typeof index === "number") {
			this.removeHomebrewByIndex(index);
		}

		const constructor = this.constructor as typeof InfoView;
		const dialogId = constructor.makeDialogId(info);

		this.dialogs.append(
			renderTemplate("#info-token-dialog-template", {
				".js--info-token--dialog"(element) {

					element.id = dialogId;
					element.style.setProperty(
						"--colour",
						`var(--${info.colour})`
					);

				},
				".js--info-token--actions"(element) {
					element.querySelectorAll("button").forEach((button) => {
						button.dataset.index = String(info.index);
					});
				}
			})
		);

		this.homebrew.append(
			renderTemplate("#info-token-button-template", {
				".js--info-token--wrapper"(element) { // <li>
					element.dataset.index = String(info.index);
				},
				".js--info-token--button"(element) {
					element.dataset.dialog = `#${dialogId}`;
				}
			})
		);

		this.updateHomebrew(info);

	}

	updateHomebrew(info: IInfoData_demo) {

		const constructor = this.constructor as typeof InfoView;
		const dialogId = constructor.makeDialogId(info);

		const dialog = this.dialogs.querySelector<HTMLElement>(`#${dialogId}`);
		const homebrew = this.homebrew.querySelector<HTMLElement>(
			`.js--info-token--wrapper[dataset-index="${info.index}"]`
		);

		if (!dialog || !homebrew) {
			return;
		}

		updateChildren(dialog, {
			".js--info-token--dialog-text"(element) {
				element.innerHTML = markdownToHTML(info.text);
			}
		});

		updateChildren(homebrew, {
			".js--info-token--button"(element) {

				element.textContent = stripMarkdown(info.text);
				element.style.setProperty(
					"--bg-colour",
					`var(--${info.colour})`
				);

			}
		});

	}

}

The InfoController can then be updated.

class InfoController extends Controller<InfoModel, InfoView> {

	render(): void {

		super.render();

		const {
			model,
			view
		} = this;

		// ...

		model.on("info-update", (info) => {
			view.updateHomebrew(info);
		});

	}

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment