Last active
January 26, 2024 22:58
-
-
Save Skateside/48f467940cfab0525b262136e8928c49 to your computer and use it in GitHub Desktop.
Just thinking aloud about re-writing the Pocket Grimoire
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[]>; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
You can simplify NightOrderView#drawNights
class NightOrderView extends View {
//...
drawNights({ first, other }: IRepositoryNightsRoles_demo) {
this.firstNight.replaceChildren(
...first.map((role) => this.drawEntry(role, "first"))
)
this.otherNight.replaceChildren(
...other.map((role) => this.drawEntry(role, "other"))
)
}
}
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) {
// ...
}
}
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}`;
});
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
A better
querySelectorCached
andmemoise
: