diff --git a/db.sqlite b/db.sqlite index 6f3c060..bc673f4 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/shared/character.util.ts b/shared/character.util.ts index ed5599f..6e3a46e 100644 --- a/shared/character.util.ts +++ b/shared/character.util.ts @@ -1,5 +1,5 @@ -import type { Ability, Alignment, ArmorConfig, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, ItemConfig, ItemState, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponType } from "~/types/character"; -import { keyof, z } from "zod/v4"; +import type { Ability, Alignment, ArmorConfig, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, ItemConfig, ItemState, Level, MainStat, Resistance, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponType } from "~/types/character"; +import { z } from "zod/v4"; import characterConfig from '#shared/character-config.json'; import proses, { preview } from "#shared/proses"; import { button, buttongroup, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; @@ -11,6 +11,7 @@ import { getText } from "#shared/i18n"; import type { User } from "~/types/auth"; import { MarkdownEditor } from "#shared/editor.util"; import { Socket } from "#shared/websocket.util"; +import { raw, reactive, reactivity, type Reactive } from '#shared/reactive'; const config = characterConfig as CharacterConfig; @@ -295,8 +296,8 @@ export class CharacterCompiler set character(value: Character) { - this._character = value; - this._result = defaultCompiledCharacter(value); + this._character = reactive(value); + this._result = reactive(defaultCompiledCharacter(value)); this._buffer = { 'modifier/strength': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/dexterity': { value: 0, _dirty: false, min: -Infinity, list: [] }, @@ -306,6 +307,11 @@ export class CharacterCompiler 'modifier/charisma': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/psyche': { value: 0, _dirty: false, min: -Infinity, list: [] }, }; + reactivity(() => this.character.variables, () => { + console.log("Saving variables"); + clearTimeout(this._variableDebounce); + this._variableDebounce = setTimeout(() => this.saveVariables(), 2000); + }) if(value.people !== undefined) { @@ -354,30 +360,14 @@ export class CharacterCompiler return substring; }) } - variable(prop: T, value: CharacterVariables[T], autosave: boolean = true) - { - this._character.variables[prop] = value; - this._result.variables[prop] = value; - this._variableDirty = true; - - if(autosave) - { - clearTimeout(this._variableDebounce); - this._variableDebounce = setTimeout(() => this.saveVariables(), 2000); - } - } saveVariables() { - if(this._variableDirty) - { - this._variableDirty = false; - useRequestFetch()(`/api/character/${this.character.id}/variables`, { - method: 'POST', - body: this._character.variables, - }).then(() => {}).catch(() => { - Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); - }) - } + useRequestFetch()(`/api/character/${this.character.id}/variables`, { + method: 'POST', + body: raw(this._character.variables), + }).then(() => {}).catch(() => { + Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); + }) } saveNotes() { @@ -1006,11 +996,11 @@ class TrainingPicker extends BuilderTab ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Vie" }), - text(this._builder, '{{compiled.health}}'), + text(() => this._builder.compiled.health), ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Mana" }), - text(this._builder, '{{compiled.mana}}'), + text(() => this._builder.compiled.mana), ]), button(text('Suivant'), () => this._builder.display(3), 'h-[35px] px-[15px]'), ]), dom('span') @@ -1321,14 +1311,14 @@ export class CharacterSheet useRequestFetch()(`/api/character/${id}`).then(character => { if(character) { - this.character!.character = character; + this.character!.character = reactive(character); this.character!.values; 'update' in this.container! && this.container!.update!(true); } }); }) this.ws.handleMessage<{ action: 'set' | 'add' | 'remove', key: keyof CharacterVariables, value: any }>('VARIABLE', (variable) => { - const prop = this.character?.character.variables[variable.key]; + /* const prop = this.character?.character.variables[variable.key]; if(variable.action === 'set') this.character?.variable(variable.key, variable.value, false); else if(Array.isArray(prop)) @@ -1341,7 +1331,7 @@ export class CharacterSheet if(idx !== -1) prop.splice(idx, 1); } this.character?.variable(variable.key, prop, false); - } + } */ }) } @@ -1384,19 +1374,15 @@ export class CharacterSheet privateNotes.content = this.character!.character.notes!.private!; const validateProperty = (v: string, property: 'health' | 'mana', obj: { edit: HTMLInputElement, readonly: RedrawableHTML }) => { - const value = v.startsWith('-') ? character.variables[property] + parseInt(v.substring(1), 10) : v.startsWith('+') ? character.variables[property] - parseInt(v.substring(1), 10) : character[property] - parseInt(v, 10); - this.character?.variable(property, clamp(isNaN(value) ? character.variables[property] : value, 0, Infinity)); - this.character?.saveVariables(); - - obj.edit.value = (character[property] - this.character!.character.variables[property]).toString(); - obj.readonly.textContent = (character[property] - character.variables[property]).toString(); + character.variables[property] = v.startsWith('-') ? character.variables[property] + parseInt(v.substring(1), 10) : v.startsWith('+') ? character.variables[property] - parseInt(v.substring(1), 10) : character[property] - parseInt(v, 10); + obj.edit.value = (character[property] - character.variables[property]).toString(); obj.edit.replaceWith(obj.readonly); }; const health = { readonly: dom("span", { class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", - text: `${character.health - character.variables.health}`, + text: () => `${character.health - character.variables.health}`, listeners: { click: () => { health.readonly.replaceWith(health.edit); health.edit.select(); health.edit.focus(); } }, }), edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => { @@ -1407,7 +1393,7 @@ export class CharacterSheet const mana = { readonly: dom("span", { class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", - text: `${character.mana - character.variables.mana}`, + text: () => `${character.mana - character.variables.mana}`, listeners: { click: () => { mana.readonly.replaceWith(mana.edit); mana.edit.select(); mana.edit.focus(); } }, }), edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => { @@ -1458,12 +1444,12 @@ export class CharacterSheet dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [ text("PV: "), health.readonly, - text(character, `/ {{health}}`), + text(() => character.health), ]), dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [ text("Mana: "), mana.readonly, - text(character, `/ {{mana}}`), + text(() => character.mana), ]), ]), ]), @@ -1478,31 +1464,31 @@ export class CharacterSheet div("flex flex-row justify-center 2xl:gap-4 gap-2 p-4 border-b border-light-35 dark:border-dark-35", [ div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [ div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.strength}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.strength}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Force" }) ]), div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.dexterity}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.dexterity}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Dextérité" }) ]), div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.constitution}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.constitution}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Constitution" }) ]), div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.intelligence}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.intelligence}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Intelligence" }) ]), div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.curiosity}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.curiosity}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Curiosité" }) ]), div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.charisma}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.charisma}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Charisme" }) ]), div("flex flex-col items-center px-2", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{modifier.psyche}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.modifier.psyche}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Psyché" }) ]) ]), @@ -1511,11 +1497,11 @@ export class CharacterSheet div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [ div("flex flex-col px-2 items-center", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `+{{initiative}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.initiative}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Initiative" }) ]), div("flex flex-col px-2 items-center", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, () => character.speed === false ? "Aucun déplacement" : `{{speed}} cases`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => character.speed === false ? "Aucun déplacement" : `${character.speed} cases`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Course" }) ]) ]), @@ -1525,15 +1511,15 @@ export class CharacterSheet div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [ icon("game-icons:checked-shield", { width: 32, height: 32 }), div("flex flex-col px-2 items-center", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `{{defense.passive}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => clamp(character.defense.static + character.defense.passiveparry + character.defense.passivedodge, 0, character.defense.hardcap)) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Passive" }) ]), div("flex flex-col px-2 items-center", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `{{defense.parry}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => clamp(character.defense.static + character.defense.activeparry + character.defense.passivedodge, 0, character.defense.hardcap)) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Blocage" }) ]), div("flex flex-col px-2 items-center", [ - dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(character, `{{defense.dodge}}`) ]), + dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => clamp(character.defense.static + character.defense.passiveparry + character.defense.activedodge, 0, character.defense.hardcap)) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Esquive" }) ]) ]), @@ -1551,7 +1537,7 @@ export class CharacterSheet Object.keys(character.abilities).map((ability) => div("flex flex-row px-1 justify-between items-center", [ span("text-sm text-light-70 dark:text-dark-70 max-w-20 truncate", abilityTexts[ability as Ability] || ability), - span("font-bold text-base text-light-100 dark:text-dark-100", text(character.abilities, `+{{${ability}}}`)), + span("font-bold text-base text-light-100 dark:text-dark-100", text(() => `+${character.abilities[ability as Ability] ?? 0}`)), ]) ) ), @@ -1583,10 +1569,10 @@ export class CharacterSheet ]) : undefined, div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ - div('flex flex-row items-center gap-2', [ text('Précision'), span('font-bold', text(character.spellranks, "{{precision}}")) ]), - div('flex flex-row items-center gap-2', [ text('Savoir'), span('font-bold', text(character.spellranks, "{{knowledge}}")) ]), - div('flex flex-row items-center gap-2', [ text('Instinct'), span('font-bold', text(character.spellranks, "{{instinct}}")) ]), - div('flex flex-row items-center gap-2', [ text('Oeuvres'), span('font-bold', text(character.spellranks, "{{arts}}")) ]), + div('flex flex-row items-center gap-2', [ text('Précision'), span('font-bold', text(() => character.spellranks.precision)) ]), + div('flex flex-row items-center gap-2', [ text('Savoir'), span('font-bold', text(() => character.spellranks.knowledge)) ]), + div('flex flex-row items-center gap-2', [ text('Instinct'), span('font-bold', text(() => character.spellranks.instinct)) ]), + div('flex flex-row items-center gap-2', [ text('Oeuvres'), span('font-bold', text(() => character.spellranks.arts)) ]), ]) ]) ]), @@ -1659,50 +1645,52 @@ export class CharacterSheet } spellTab(character: CompiledCharacter) { - let sortPreference = (localStorage.getItem('character-sort') ?? 'rank') as 'rank' | 'type' | 'element'; + const preference = reactive({ + sort: localStorage.getItem('character-sort') ?? 'rank', + } as { sort: 'rank' | 'type' | 'element' }); - const sort = () => { - switch(sortPreference) + const sort = (spells: string[]) => { + localStorage.setItem('character-sort', preference.sort); + + switch(preference.sort) { - case 'rank': return container.array.sort((a, b) => (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0) || SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!)); - case 'type': return container.array.sort((a, b) => config.spells[a]?.type.localeCompare(config.spells[b]?.type ?? '') || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); - case 'element': return container.array.sort((a, b) => SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!) || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); - default: return container.array; + case 'rank': return spells.sort((a, b) => (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0) || SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!)); + case 'type': return spells.sort((a, b) => config.spells[a]?.type.localeCompare(config.spells[b]?.type ?? '') || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); + case 'element': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!) || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); + default: return spells; } }; - const container = div('flex flex-col gap-2', { render: e => { - const spell = config.spells[e]; - - if(!spell) - return; - - return div('flex flex-col gap-2', [ - div('flex flex-row items-center gap-4', [ dom('span', { class: 'font-semibold text-lg', text: spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${spell.cost ?? 0} mana` }) ]), - div('flex flex-row justify-between items-center gap-2 text-light-70 dark:text-dark-70', [ - div('flex flex-row gap-2', [ span('flex flex-row', spell.rank === 4 ? 'Sort unique' : `Sort ${spell.type === 'instinct' ? 'd\'instinct' : spell.type === 'knowledge' ? 'de savoir' : 'de précision'} de rang ${spell.rank}`), ...(spell.elements ?? []).map(elementDom) ]), - div('flex flex-row gap-4 items-center', [ spell.concentration ? proses('a', preview, [span('italic text-sm', 'concentration')], { href: '' }) : undefined, span(undefined, typeof spell.range === 'number' && spell.range > 0 ? `${spell.range} case${spell.range > 1 ? 's' : ''}` : spell.range === 0 ? 'toucher' : 'personnel'), span(undefined, typeof spell.speed === 'number' ? `${spell.speed} minute${spell.speed > 1 ? 's' : ''}` : spell.speed) ]) - ]), - div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(spell.description) ]), - ]) - }, list: [...(character.lists.spells ?? []), ...character.variables.spells] }); - sort().render(); + return [ div('flex flex-col gap-2', [ div('flex flex-row justify-between items-center', [ div('flex flex-row gap-2 items-center', [ dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }), - buttongroup<'rank' | 'type' | 'element'>([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: sortPreference, class: { option: 'px-2 py-1 text-sm' }, onChange: (value) => { localStorage.setItem('character-sort', value); sortPreference = value; sort().render(); } }), + buttongroup<'rank' | 'type' | 'element'>([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: preference.sort as 'rank' | 'type' | 'element', class: { option: 'px-2 py-1 text-sm' }, onChange: (v) => { preference.sort = v; } }), ]), div('flex flex-row gap-2 items-center', [ - dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length !== character.spellslots }], text: `${character.variables.spells.length}/${character.spellslots} sort${character.variables.spells.length > 1 ? 's' : ''} maitrisé${character.variables.spells.length > 1 ? 's' : ''}` }), + dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length + (character.lists.spells?.length ?? 0) !== character.spellslots }], text: () => `${character.variables.spells.length + (character.lists.spells?.length ?? 0)}/${character.spellslots} sort(s) maitrisé(s)`.replaceAll('(s)', character.variables.spells.length + (character.lists.spells?.length ?? 0) > 1 ? 's' : '') }), button(text('Modifier'), () => this.spellPanel(character), 'py-1 px-4'), ]) ]), - container, + div('flex flex-col gap-2', { render: e => { + const spell = config.spells[e]; + + if(!spell) + return; + + return div('flex flex-col gap-2', [ + div('flex flex-row items-center gap-4', [ dom('span', { class: 'font-semibold text-lg', text: spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${spell.cost ?? 0} mana` }) ]), + div('flex flex-row justify-between items-center gap-2 text-light-70 dark:text-dark-70', [ + div('flex flex-row gap-2', [ span('flex flex-row', `Sort ${spell.type === 'instinct' ? 'd\'instinct' : spell.type === 'knowledge' ? 'de savoir' : 'de précision'} ${spell.rank === 4 ? 'unique' :`de rang ${spell.rank}`}`), ...(spell.elements ?? []).map(elementDom) ]), + div('flex flex-row gap-4 items-center', [ spell.concentration ? proses('a', preview, [span('italic text-sm', 'concentration')], { href: '' }) : undefined, span(undefined, typeof spell.range === 'number' && spell.range > 0 ? `${spell.range} case${spell.range > 1 ? 's' : ''}` : spell.range === 0 ? 'toucher' : 'personnel'), span(undefined, typeof spell.speed === 'number' ? `${spell.speed} minute${spell.speed > 1 ? 's' : ''}` : spell.speed) ]) + ]), + div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(spell.description) ]), + ]) + }, list: () => sort([...(character.lists.spells ?? []), ...character.variables.spells]) }), ]) ] } - //TODO: Update to handle reactivity spellPanel(character: CompiledCharacter) { const availableSpells = Object.values(config.spells).filter(spell => { @@ -1710,61 +1698,48 @@ export class CharacterSheet if (character.spellranks[spell.type] < spell.rank) return false; return true; }); + const spells = character.variables.spells; - const textAmount = text(character.variables.spells.length.toString()), textMax = text(character.spellslots.toString()); const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ div("flex flex-row justify-between items-center mb-4", [ dom("h2", { class: "text-xl font-bold", text: "Ajouter un sort" }), - div('flex flex-row gap-4 items-center', [ dom('span', { class: 'italic text-light-70 dark:text-dark-70 text-sm' }, [ textAmount, text(' / '), textMax, text(' sorts maitrisés') ]), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => { + div('flex flex-row gap-4 items-center', [ dom('span', { class: 'italic text-light-70 dark:text-dark-70 text-sm' }, [ text(() => `${spells.length + (character.lists.spells?.length ?? 0)}/${character.spellslots} sort(s) maitrisé(s)`.replaceAll('(s)', spells.length + (character.lists.spells?.length ?? 0) > 1 ? 's' : '')) ]), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }, "p-1"), "Fermer", "left") ]) ]), - div('flex flex-col divide-y *:py-2 -my-2 overflow-y-auto', availableSpells.map(spell => { - let state = character.lists.spells?.includes(spell.id) ? 'given' : character.variables.spells.includes(spell.id) ? 'choosen' : 'empty'; - const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => { - if(state === 'choosen') - { - this.character!.variable('spells', character.variables.spells.filter(e => e !== spell.id)); - state = 'empty'; - } - else if(state === 'empty') - { - this.character!.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK - state = 'choosen'; - } - toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; - textAmount.textContent = character.variables.spells.length.toString(); - }, "px-2 py-1 text-sm font-normal"); - toggleButton.disabled = state === 'given'; - return foldable(() => [ + div('flex flex-col divide-y *:py-2 -my-2 overflow-y-auto', { list: availableSpells, render: (spell) => foldable(() => [ markdown(spell.description), ], [ div("flex flex-row justify-between gap-2", [ - dom("span", { class: "text-lg font-bold", text: spell.name }), - div("flex flex-row items-center gap-6", [ - div("flex flex-row text-sm gap-2", - spell.elements.map(el => - dom("span", { - class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[el].class], - text: elementTexts[el].text - }) - ) - ), - div("flex flex-row text-sm gap-1", [ - ...(spell.rank !== 4 ? [ - dom("span", { text: `Rang ${spell.rank}` }), - text("/"), - dom("span", { text: spellTypeTexts[spell.type] }), - text("/") - ] : []), - dom("span", { text: `${spell.cost} mana` }), + dom("span", { class: "text-lg font-bold", text: spell.name }), + div("flex flex-row items-center gap-6", [ + div("flex flex-row text-sm gap-2", + spell.elements.map(el => + dom("span", { + class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[el].class], + text: elementTexts[el].text + }) + ) + ), + div("flex flex-row text-sm gap-1", [ + ...(spell.rank !== 4 ? [ + dom("span", { text: `Rang ${spell.rank}` }), text("/"), - dom("span", { text: typeof spell.speed === "string" ? spell.speed : `${spell.speed} minutes` }) - ]), - toggleButton, + dom("span", { text: spellTypeTexts[spell.type] }), + text("/") + ] : []), + dom("span", { text: `${spell.cost} mana` }), + text("/"), + dom("span", { text: typeof spell.speed === "string" ? spell.speed : `${spell.speed} minutes` }) ]), - ]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } }); - })) + button(text(() => spells.includes(spell.id) ? 'Supprimer' : character.lists.spells?.includes(spell.id) ? 'Inné' : 'Ajouter'), () => { + const idx = spells.findIndex(e => e === spell.id); + if(idx !== -1) spells.splice(idx, 1); + else spells.push(spell.id); + }, "px-2 py-1 text-sm font-normal"), + ]), + ]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } }) + }) ]); const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() }); setTimeout(() => container.setAttribute('data-state', 'active'), 1); @@ -1772,17 +1747,17 @@ export class CharacterSheet itemsTab(character: CompiledCharacter) { const items = character.variables.items; - const power = items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0); - const weight = items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); + const power = () => items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0); + const weight = () => items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); return [ div('flex flex-col gap-2', [ div('flex flex-row justify-end items-center gap-8', [ - dom('span', { class: ['italic text-sm', () => ({ 'text-light-red dark:text-dark-red': weight > character.itempower })], text: () => `Poids total: ${weight}/${character.itempower}` }), - dom('span', { class: ['italic text-sm', () => ({ 'text-light-red dark:text-dark-red': power > (character.capacity === false ? 0 : character.capacity) })], text: () => `Puissance magique: ${power}/${character.capacity}` }), + dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': weight() > character.itempower }], text: () => `Poids total: ${weight()}/${character.itempower}` }), + dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': power() > (character.capacity === false ? 0 : character.capacity) }], text: () => `Puissance magique: ${power()}/${character.capacity}` }), button(text('Modifier'), () => this.itemsPanel(character), 'py-1 px-4'), ]), - div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: character.variables.items, redraw: true, render: e => { + div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: character.variables.items, render: e => { const item = config.items[e.id]; if(!item) return; @@ -1801,25 +1776,19 @@ export class CharacterSheet items[idx]!.amount--; if(items[idx]!.amount <= 0) items.splice(idx, 1); - else (items as DOMList)?.render(); - - this.character!.variable('items', items); }, 'p-1'), button(icon('radix-icons:plus', { width: 12, height: 12 }), () => { const idx = items.findIndex(_e => _e === e); if(idx === -1) return; if(item.equippable) items.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: false }); - else if(items.find(_e => _e === e)) { items.find(_e => _e === e)!.amount++; (items as DOMList)?.render(); } + else if(items.find(_e => _e === e)) items.find(_e => _e === e)!.amount++; else items.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [] }); - this.character!.variable('items', items); }, 'p-1'), ]) ], [div('flex flex-row justify-between', [ div('flex flex-row items-center gap-4', [ item.equippable ? checkbox({ defaultValue: e.equipped, change: v => { e.equipped = v; - - this.character!.variable('items', items); }, class: { container: '!w-5 !h-5' } }) : undefined, div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]), ]), @@ -1837,41 +1806,13 @@ export class CharacterSheet } itemsPanel(character: CompiledCharacter) { - const items = Object.values(config.items).map(item => ({ item, dom: foldable(() => [ markdown(getText(item.description)) ], [div('flex flex-row justify-between', [ - div('flex flex-row items-center gap-4', [ - div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]), - ]), - div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [ - div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.powercost || item.capacity ? `${item.powercost ?? 0}/${item.capacity ?? 0}` : '-') ]), - div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.weight?.toString() ?? '-') ]), - div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.charge ? `${item.charge}` : '-') ]), - div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.price ? `${item.price}` : '-') ]), - button(icon('radix-icons:plus', { width: 16, height: 16 }), () => { - const list = this.character!.character.variables.items; - if(item.equippable) list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: false }); - else if(list.find(e => e.id === item.id)) list.find(e => e.id === item.id)!.amount++; - else list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [] }); - (list as DOMList)?.render(); - this.character!.variable('items', list); - }, 'p-1 !border-solid !border-r'), - ]), - ])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } }) })); - - const filters: { category: Category[], rarity: Rarity[], name: string, power: { min: number, max: number } } = { + const filters: { category: Category[], rarity: Rarity[], name: string, power: { min: number, max: number } } = reactive({ category: [], rarity: [], name: '', power: { min: 0, max: Infinity }, - }; - const applyFilters = () => { - content.replaceChildren(...items.filter(e => - (filters.category.length === 0 || filters.category.includes(e.item.category)) && - (filters.rarity.length === 0 || filters.rarity.includes(e.item.rarity)) && - (filters.name === '' || e.item.name.toLowerCase().includes(filters.name.toLowerCase())) - ).map(e => e.dom)); - } + }); - const content = div('grid grid-cols-1 -my-2 overflow-y-auto gap-1'); const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ div("flex flex-row justify-between items-center mb-4", [ dom("h2", { class: "text-xl font-bold", text: "Gestion de l'inventaire" }), @@ -1881,13 +1822,39 @@ export class CharacterSheet }, "p-1"), "Fermer", "left") ]) ]), div('flex flex-row items-center gap-4', [ - div('flex flex-row gap-2 items-center', [ text('Catégorie'), multiselect(Object.keys(categoryText).map(e => ({ text: categoryText[e as Category], value: e as Category })), { defaultValue: filters.category, change: v => { filters.category = v; applyFilters(); }, class: { container: 'w-40' } }) ]), - div('flex flex-row gap-2 items-center', [ text('Rareté'), multiselect(Object.keys(rarityText).map(e => ({ text: rarityText[e as Rarity], value: e as Rarity })), { defaultValue: filters.rarity, change: v => { filters.rarity = v; applyFilters(); }, class: { container: 'w-40' } }) ]), - div('flex flex-row gap-2 items-center', [ text('Nom'), input('text', { defaultValue: filters.name, input: v => { filters.name = v; applyFilters(); }, class: 'w-64' }) ]), + div('flex flex-row gap-2 items-center', [ text('Catégorie'), multiselect(Object.keys(categoryText).map(e => ({ text: categoryText[e as Category], value: e as Category })), { defaultValue: filters.category, change: v => filters.category = v, class: { container: 'w-40' } }) ]), + div('flex flex-row gap-2 items-center', [ text('Rareté'), multiselect(Object.keys(rarityText).map(e => ({ text: rarityText[e as Rarity], value: e as Rarity })), { defaultValue: filters.rarity, change: v => filters.rarity = v, class: { container: 'w-40' } }) ]), + div('flex flex-row gap-2 items-center', [ text('Nom'), input('text', { defaultValue: filters.name, input: v => { filters.name = v; }, class: 'w-64' }) ]), ]), - content, + div('grid grid-cols-1 -my-2 overflow-y-auto gap-1', { list: () => Object.values(config.items).filter(item => + (filters.category.length === 0 || filters.category.includes(item.category)) && + (filters.rarity.length === 0 || filters.rarity.includes(item.rarity)) && + (filters.name === '' || item.name.toLowerCase().includes(filters.name.toLowerCase())) + ), render: (e) => { + const item = config.items[e.id]; + + if(!item) + return; + + return foldable(() => [ markdown(getText(item.description)) ], [div('flex flex-row justify-between', [ + div('flex flex-row items-center gap-4', [ + div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]), + ]), + div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [ + div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.powercost || item.capacity ? `${item.powercost ?? 0}/${item.capacity ?? 0}` : '-') ]), + div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.weight?.toString() ?? '-') ]), + div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.charge ? `${item.charge}` : '-') ]), + div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.price ? `${item.price}` : '-') ]), + button(icon('radix-icons:plus', { width: 16, height: 16 }), () => { + const list = this.character!.character.variables.items; + if(item.equippable) list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: false }); + else if(list.find(e => e.id === item.id)) list.find(e => e.id === item.id)!.amount++; + else list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [] }); + }, 'p-1 !border-solid !border-r'), + ]), + ])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } }) + } }), ]); - applyFilters(); const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() }); setTimeout(() => container.setAttribute('data-state', 'active'), 1); } diff --git a/shared/components.util.ts b/shared/components.util.ts index 78d1992..decab24 100644 --- a/shared/components.util.ts +++ b/shared/components.util.ts @@ -1,9 +1,10 @@ import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router"; -import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node, type RedrawableHTML, type Reactive } from "./dom.util"; -import { contextmenu, followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "./floating.util"; -import { clamp } from "./general.util"; -import { Tree } from "./tree"; +import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node, type RedrawableHTML } from "#shared/dom.util"; +import { contextmenu, followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "#shared/floating.util"; +import { clamp } from "#shared/general.util"; +import { Tree } from "#shared/tree"; import type { Placement } from "@floating-ui/dom"; +import { type Reactive } from '#shared/reactive'; export function link(children: NodeChildren, properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped) { diff --git a/shared/dom.util.ts b/shared/dom.util.ts index 88b278c..81e0ebc 100644 --- a/shared/dom.util.ts +++ b/shared/dom.util.ts @@ -1,18 +1,17 @@ import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon'; import { loading } from './components.util'; -import { deepEquals } from './general.util'; +import { _defer, reactivity, type Proxy, type Reactive } from './reactive'; export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void } export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined; export type NodeChildren = Array> | undefined; -export type Class = Reactive | Record | undefined>; +export type Class = string | Array | Record | undefined; type Listener = | ((this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any) | { options?: boolean | AddEventListenerOptions; listener: (this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any; } | undefined; -export type Reactive = T | (() => T); export interface DOMList extends Array{ render(redraw?: boolean): void; }; @@ -21,288 +20,166 @@ export interface NodeProperties { attributes?: Record>; text?: Reactive; - class?: Class; + class?: Reactive; style?: Reactive | string>; listeners?: { [K in keyof HTMLElementEventMap]?: Listener }; } -let defered = false, _deferSet = new Set<() => void>(); -const _defer = (fn: () => void) => { - if(!defered) - { - defered = true; - queueMicrotask(() => { - _deferSet.forEach(e => e()); - _deferSet.clear(); - defered = false; - }); - } - - _deferSet.add(fn); -} -let reactiveEffect: (() => void) | null = null; -const _reactiveCache = new WeakMap(); -// Store a Weak map of all the tracked object. -// For each object, we have a map of its properties, allowing us to effectively listen to absolutely everything on the object -// For a given property, we have a set of "effect" (function called on value update) -const _tracker = new WeakMap<{}, Map void>>>(); -function trigger(target: T, key: string | symbol) -{ - const dependencies = _tracker.get(target) - if(!dependencies) return; - - const set = dependencies.get(key); - set?.forEach(e => e()); -} -function track(target: T, key: string | symbol) -{ - if(!reactiveEffect) return; - - let dependencies = _tracker.get(target); - if(!dependencies) - { - dependencies = new Map(); - _tracker.set(target, dependencies); - } - - let set = dependencies.get(key); - if(!set) - { - set = new Set(); - dependencies.set(key, set); - } - - set.add(reactiveEffect); -} -export function reactive(obj: T): T -{ - if(_reactiveCache.has(obj)) - return _reactiveCache.get(obj)!; - - const proxy = new Proxy(obj, { - get: (target, key, receiver) => { - track(target, key); - const value = Reflect.get(target, key, receiver); - - if(value && typeof value === 'object') - return reactive(value); - - return value; - }, - set: (target, key, value, receiver) => { - const old = Reflect.get(target, key, receiver); - const result = Reflect.set(target, key, value, receiver); - - if(old !== value) - _defer(() => trigger(target, key)); - - return result; - }, - }); - - _reactiveCache.set(obj, proxy); - return proxy; -} -function requireReactive(reactiveProperty: Reactive, effect: (processed: T) => void) -{ - if(typeof reactiveProperty !== 'function') - return effect(reactiveProperty); - else - { - // Function wrapping to keep the context safe and secured. - // Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example) - const secureEffect = () => effect((reactiveProperty as () => T)()); - const secureContext = () => { - reactiveEffect = secureContext; - try { - secureEffect(); - } finally { - reactiveEffect = null; - } - }; - - secureContext(); - } -} - export const cancelPropagation = (e: Event) => e.stopImmediatePropagation(); -export function dom(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T] & { update?: (recursive: boolean) => void }; -export function dom(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList, update?: (recursive: boolean) => void }; -export function dom(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList, update?: (recursive: boolean) => void } +export function dom(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T]; +export function dom(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Reactive> }): HTMLElementTagNameMap[T] & { array?: DOMList }; +export function dom(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Reactive> }): HTMLElementTagNameMap[T] & { array?: DOMList } { - const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList, update: (recursive: boolean) => void }; - let setup = true, updating = false; - + const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList }; const _cache = new Map(); - const update = (recursive: boolean) => { - updating = true; - - if(children !== undefined && (setup || recursive)) + if(children) + { + if(Array.isArray(children)) { - if(Array.isArray(children)) + for(const c of children) { - for(const c of children) - { - const child = typeof c === 'function' ? c() : c; - if(child !== undefined) + const child = typeof c === 'function' ? c() : c; + child && element.appendChild(child); + } + } + else if(children.list !== undefined) + { + reactivity(children.list, (list) => { + element.replaceChildren(); + list?.forEach(e => { + let dom = _cache.get(e); + if(!dom) { - element.appendChild(child); - recursive && 'update' in child && _defer(() => child.update!(true)); + dom = children.render(e); + _cache.set(e, dom); } - } - } - else if(children.list !== undefined) - { - if(setup) - { - children.list.forEach(e => _cache.set(e, children.render(e))); - - const _push = children.list.push; - children.list.push = (...items: U[]) => { - items.forEach(e => { - if(!_cache.has(e)) - { - const dom = children.render(e); - _cache.set(e, dom); - dom && element.appendChild(dom); - } - else - { - const dom = _cache.get(e); - dom && element.appendChild(dom); - } - }); - if(children.redraw) _defer(() => update(false)); - return _push.bind(children.list)(...items); - }; - const _splice = children.list.splice; - children.list.splice = (start: number, deleteCount: number, ...items: U[]) => { - const list = _splice.bind(children.list)(start, deleteCount, ...items); - list.forEach(e => { if(!children.list!.find(_e => _e === e)) _cache.delete(e); }); - element.array!.render(); - return list; - }; - } - else if(recursive) - _cache.forEach((v, k) => v && 'update' in v && v.update!(true)); - - element.array = children.list as DOMList; - element.array.render = (redraw?: boolean) => { - element.replaceChildren(...children.list?.map(e => _cache.get(e)).filter(e => !!e) ?? []); - if((redraw !== undefined || children.redraw !== undefined) && !updating) _defer(() => update(redraw ?? children.redraw!)); - } - - element.array.render(); - } - } - - if(properties?.attributes) - { - for(const [k, v] of Object.entries(properties.attributes)) - { - if(!setup && typeof v !== 'function') continue; - - const value = typeof v === 'function' ? v() : v; - if(typeof value === 'string' || typeof value === 'number') element.setAttribute(k, value.toString(10)); - else if(typeof value === 'boolean') element.toggleAttribute(k, value); - } - } - - if(properties?.text && (setup || typeof properties.text === 'function')) - { - requireReactive(properties.text, (text) => { - if(typeof text === 'string') - element.textContent = text; - else if(typeof text === 'number') - element.textContent = text.toString(); - else - element.appendChild(text as Text); + dom && element.appendChild(dom); + }); }) } + } - if(properties?.listeners && setup) + if(properties?.attributes) + { + for(const [k, v] of Object.entries(properties.attributes)) { - for(let [k, v] of Object.entries(properties.listeners)) - { - const key = k as keyof HTMLElementEventMap, value = v as Listener; - if(typeof value === 'function') - element.addEventListener(key, value.bind(element)); - else if(value) - element.addEventListener(key, value.listener.bind(element), value.options); - } + reactivity(properties.attributes[k], (attribute) => { + if(typeof attribute === 'string' || typeof attribute === 'number') element.setAttribute(k, attribute.toString(10)); + else if(typeof attribute === 'boolean') element.toggleAttribute(k, attribute); + }); } + } - styling(element, properties ?? {}); - updating = false; - }; + if(properties?.text) + { + reactivity(properties.text, (text) => { + if(typeof text === 'string') + element.textContent = text; + else if(typeof text === 'number') + element.textContent = text.toString(); + else + element.appendChild(text as Text); + }) + } - update(false); - setup = false; - element.update = update; + if(properties?.listeners) + { + for(let [k, v] of Object.entries(properties.listeners)) + { + const key = k as keyof HTMLElementEventMap, value = v as Listener; + if(typeof value === 'function') + element.addEventListener(key, value.bind(element)); + else if(value) + element.addEventListener(key, value.listener.bind(element), value.options); + } + } + + if(properties?.class) + { + reactivity(properties?.class, (classes) => element.setAttribute('class', mergeClasses(classes))); + } + + if(properties?.style) + { + reactivity(properties.style, (style) => { + if(typeof style === 'string') element.setAttribute('style', style); + else for(const [k, v] of Object.entries(style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v); + }) + } return element; } -export function div(cls?: Class, children?: NodeChildren): HTMLElementTagNameMap['div'] & { update?: (recursive: boolean) => void } -export function div(cls?: Class, children?: { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array: DOMList, update?: (recursive: boolean) => void } -export function div(cls?: Class, children?: NodeChildren | { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array?: DOMList, update?: (recursive: boolean) => void } +export function div(cls?: Reactive, children?: NodeChildren): HTMLElementTagNameMap['div'] +export function div(cls?: Reactive, children?: { render: (data: U) => Node, list?: Reactive> }): HTMLElementTagNameMap['div'] & { array: DOMList } +export function div(cls?: Reactive, children?: NodeChildren | { render: (data: U) => Node, list?: Reactive> }): HTMLElementTagNameMap['div'] & { array?: DOMList } { - //@ts-expect-error + //@ts-expect-error wtf is wrong here ??? return dom("div", { class: cls }, children); } -export function span(cls?: Class, text?: Reactive): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } +export function span(cls?: Reactive, text?: Reactive): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } { return dom("span", { class: cls, text: text }); } -export function svg(tag: K, properties?: NodeProperties, children?: SVGElement[]): SVGElementTagNameMap[K] +export function svg(tag: K, properties?: NodeProperties, children?: Array>): SVGElementTagNameMap[K] { const element = document.createElementNS("http://www.w3.org/2000/svg", tag); - if(children && children.length > 0) - for(const c of children) if(c !== undefined) element.appendChild(c); + if(children) + { + for(const c of children) + { + const child = typeof c === 'function' ? c() : c; + child && element.appendChild(child); + } + } if(properties?.attributes) + { for(const [k, v] of Object.entries(properties.attributes)) - if(typeof v === 'string') element.setAttribute(k, v); - else if(typeof v === 'boolean') element.toggleAttribute(k, v); + { + reactivity(properties.attributes[k], (attribute) => { + if(typeof attribute === 'string' || typeof attribute === 'number') element.setAttribute(k, attribute.toString(10)); + else if(typeof attribute === 'boolean') element.toggleAttribute(k, attribute); + }); + } + } - if(properties?.text && typeof properties.text === 'string') - element.textContent = properties.text; + if(properties?.text) + { + reactivity(properties.text, (text) => { + if(typeof text === 'string') + element.textContent = text; + else if(typeof text === 'number') + element.textContent = text.toString(); + else + element.appendChild(text as Text); + }) + } - styling(element, properties ?? {}); + if(properties?.class) + { + reactivity(properties?.class, (classes) => element.setAttribute('class', mergeClasses(classes))); + } + + if(properties?.style) + { + reactivity(properties.style, (style) => { + if(typeof style === 'string') element.setAttribute('style', style); + else for(const [k, v] of Object.entries(style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v); + }) + } return element; } export function text(data: Reactive): Text { const text = document.createTextNode(''); - requireReactive(data, (txt) => text.textContent = txt.toString()); + reactivity(data, (txt) => text.textContent = txt.toString()); return text; } -export function styling(element: SVGElement | RedrawableHTML, properties: { - class?: Class; - style?: Reactive | string>; -}): SVGElement | RedrawableHTML -{ - if(properties?.class) - { - element.setAttribute('class', mergeClasses(properties.class)); - } - - if(properties?.style) - { - if(typeof properties.style === 'string') - { - element.setAttribute('style', properties.style); - } - else - for(const [k, v] of Object.entries(properties.style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v); - } - - return element; -} export interface IconProperties { @@ -338,9 +215,8 @@ export function icon(name: string, properties?: IconProperties) return element; } -export function mergeClasses(cls: Class): string +export function mergeClasses(classes: Class): string { - const classes = typeof cls === 'function' ? cls() : cls; if(typeof classes === 'string') { return classes.trim(); diff --git a/shared/dom.virtual.util.ts b/shared/dom.virtual.util.ts index 4689678..7a0abee 100644 --- a/shared/dom.virtual.util.ts +++ b/shared/dom.virtual.util.ts @@ -21,8 +21,9 @@ export function dom(tag: K, properties?: if(properties?.text) { + const text = typeof properties.text === 'function' ? properties.text() : properties.text; children ??= []; - children?.push(properties.text); + text && children?.push(text.toString()); } if(children) diff --git a/shared/editor.util.ts b/shared/editor.util.ts index 79e0ea3..e0e7f79 100644 --- a/shared/editor.util.ts +++ b/shared/editor.util.ts @@ -10,7 +10,7 @@ import { tags } from '@lezer/highlight'; import { dom, type RedrawableHTML } from '#shared/dom.util'; import { callout as calloutExtension } from '#shared/grammar/callout.extension'; import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension'; -import { renderMarkdown } from '#shared/markdown.util'; +import renderMarkdown from '#shared/markdown.util'; import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout } from "#shared/proses"; import { tagTag, tag as tagExtension } from './grammar/tag.extension'; @@ -99,7 +99,7 @@ class CalloutWidget extends WidgetType this.content = content; this.foldable = foldable; - this.contentMD = renderMarkdown(useMarkdown().parseSync(content), { a, blockquote, tag, callout: callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th }); + this.contentMD = renderMarkdown(content, undefined, { tags: { a, blockquote, tag, callout: callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th } }); } override eq(other: CalloutWidget) { diff --git a/shared/feature.util.ts b/shared/feature.util.ts index 1bb953e..36ffa5a 100644 --- a/shared/feature.util.ts +++ b/shared/feature.util.ts @@ -18,7 +18,7 @@ const config = characterConfig as CharacterConfig; export class HomebrewBuilder { private _container: RedrawableHTML; - private _tabs: RedrawableHTML & { refresh: () => void }; + private _tabs: RedrawableHTML; private _config: CharacterConfig; private _featureEditor: FeaturePanel; diff --git a/shared/floating.util.ts b/shared/floating.util.ts index c10091e..2b28de0 100644 --- a/shared/floating.util.ts +++ b/shared/floating.util.ts @@ -1,6 +1,7 @@ import * as FloatingUI from "@floating-ui/dom"; -import { cancelPropagation, dom, svg, text, type Class, type NodeChildren, type Reactive, type RedrawableHTML } from "./dom.util"; +import { cancelPropagation, dom, svg, text, type Class, type NodeChildren, type RedrawableHTML } from "./dom.util"; import { button } from "./components.util"; +import type { Reactive } from "./reactive"; export interface FloatingProperties { diff --git a/shared/i18n.ts b/shared/i18n.ts index 5398707..b2cb6ec 100644 --- a/shared/i18n.ts +++ b/shared/i18n.ts @@ -1,9 +1,10 @@ import type { CharacterConfig, i18nID } from "~/types/character"; import characterConfig from '#shared/character-config.json'; +import type { Localized } from "~/types/general"; const config = characterConfig as CharacterConfig; -export function getText(id?: i18nID, lang?: string): string +export function getText(id?: i18nID, lang?: keyof Localized): string { - return id ? (config.texts.hasOwnProperty(id) ? config.texts[id][lang ?? "default"] : '') : ''; + return id ? (config.texts.hasOwnProperty(id) ? (config.texts[id] as Localized)[lang ?? "default"] ?? '' : '') : ''; } \ No newline at end of file diff --git a/shared/markdown.util.ts b/shared/markdown.util.ts index e53719a..f4dc5ff 100644 --- a/shared/markdown.util.ts +++ b/shared/markdown.util.ts @@ -1,5 +1,5 @@ import type { Root, RootContent } from "hast"; -import { dom, styling, text, type Class, type Node, type RedrawableHTML } from "#shared/dom.util"; +import { dom, text, type Class, type Node } from "#shared/dom.util"; import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout, type Prose } from "#shared/proses"; import { heading } from "hast-util-heading"; import { headingRank } from "hast-util-heading-rank"; @@ -64,10 +64,8 @@ export function markdownReference(content: string, filter?: string, properties?: } } - const el = renderMarkdown(data, Object.assign({}, { a, blockquote, tag, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th }, properties?.tags)); - - if(properties) styling(el, properties); - + const el = dom('div', properties, data.children.map(e => renderContent(e, Object.assign({}, { a, blockquote, tag, callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th }, properties?.tags)))); + return el; })); return state; diff --git a/shared/physics.util.ts b/shared/physics.util.ts index dd6d9cb..70d1b2d 100644 --- a/shared/physics.util.ts +++ b/shared/physics.util.ts @@ -252,14 +252,14 @@ export class SnapFinder { findEdgeSnapPosition(node: string, x: number, y: number): { x: number, y: number, node: string, direction: Direction } | undefined { - const near = [...this.spatialGrid.fetch(x, y)?.values().filter(e => e !== node).flatMap(e => this.snapPointCache.getSnapPoints(e)?.map(_e => ({ ..._e, node: e })) ?? []) ?? []].filter(e => e.type === TYPE.EDGE); + const near = [...this.spatialGrid.fetch(x, y)?.values().filter(e => e.id !== node).flatMap(e => this.snapPointCache.getSnapPoints(e.id)?.map(_e => ({ ..._e, node: e })) ?? []) ?? []].filter(e => e.type === TYPE.EDGE); let nearestDistance = this.config.threshold, nearest = undefined; for (const point of near) { const distance = Math.hypot(point.pos.x - x, point.pos.y - y); if (distance < nearestDistance) { nearestDistance = distance; - nearest = { ...point.pos, node: point.node, direction: point.side! }; + nearest = { ...point.pos, node: point.node.id, direction: point.side! }; } } @@ -290,7 +290,7 @@ export class SnapFinder { this.snapPointCache.invalidate(node); this.snapPointCache.insert(node); - const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.canvas.viewport)].flatMap(e => this.snapPointCache.getSnapPoints(e)).filter(e => !!e); + const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.canvas.viewport)].flatMap(e => this.snapPointCache.getSnapPoints(e.id)).filter(e => !!e); const bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle); return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle); @@ -354,9 +354,9 @@ export class SnapFinder { if (resizeHandle) { result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x); - result.w = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width); + result.width = offsetx ? node.width + offsetx * resizeHandle.width : this.snapToGrid(node.width); result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y); - result.h = offsety ? node.height - offsety * resizeHandle.height : this.snapToGrid(node.height); + result.height = offsety ? node.height - offsety * resizeHandle.height : this.snapToGrid(node.height); } else { diff --git a/shared/reactive.ts b/shared/reactive.ts new file mode 100644 index 0000000..a236bd0 --- /dev/null +++ b/shared/reactive.ts @@ -0,0 +1,304 @@ +export type Reactive = T | (() => T); + +export const isString = (val: unknown): val is string => typeof val === 'string'; +const isIntegerKey = (key: unknown): boolean => isString(key) && key !== 'NaN' && key[0] !== '-' && '' + parseInt(key, 10) === key; + +let defered = false, _deferSet = new Set<() => void>(); +export const _defer = (fn: () => void) => { + if(!defered) + { + defered = true; + queueMicrotask(() => { + _deferSet.forEach(e => e()); + _deferSet.clear(); + defered = false; + }); + } + + _deferSet.add(fn); +} +let activeEffect: (() => void) | null = null, _isTracking = true; +const SYMBOLS = { + PROXY: Symbol('is a proxy'), + ITERATE: Symbol('iterating'), + RAW: Symbol('raw value'), +} as const; + +function reactiveReadArray(array: T[]): T[] +{ + const _raw = raw(array) + if (_raw === array) return _raw; + track(_raw, SYMBOLS.ITERATE); + return _raw.map(wrapReactive); +} +function shallowReadArray(arr: T[]): T[] +{ + track((arr = raw(arr)), SYMBOLS.ITERATE); + return arr; +} +function iterator(self: unknown[], method: keyof Array, wrapValue: (value: any) => unknown) +{ + const arr = shallowReadArray(self); + const iter = (arr[method] as any)() as IterableIterator & { + _next: IterableIterator['next'] + }; + if (arr !== self && !isShallow(self)) + { + iter._next = iter.next; + iter.next = () => { + const result = iter._next(); + if (!result.done) result.value = wrapValue(result.value); + return result; + } + } + return iter; +} +function wrapReactive(obj: any): any +{ + return obj && typeof obj === 'object' ? reactive(obj as Proxy) : obj; +} +const arrayProto = Array.prototype +function apply(self: unknown[], method: keyof Array, fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown, wrappedRetFn?: (result: any) => unknown, args?: IArguments) +{ + const arr = shallowReadArray(self); + const needsWrap = arr !== self; + const methodFn = arr[method] as Function; + + if (methodFn !== arrayProto[method as any]) + { + const result = methodFn.apply(self, args); + return needsWrap ? toReactive(result) : result; + } + + let wrappedFn = fn; + if (arr !== self) + { + if (needsWrap) + { + wrappedFn = function (this: unknown, item, index) { + return fn.call(this, wrapReactive(item), index, self); + }; + } + else if (fn.length > 2) + { + wrappedFn = function (this: unknown, item, index) { + return fn.call(this, item, index, self); + }; + } + } + const result = methodFn.call(arr, wrappedFn, thisArg); + return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; +} +function reduce(self: unknown[], method: keyof Array, fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown, args: unknown[]) +{ + const arr = shallowReadArray(self); + let wrappedFn = fn; + if (arr !== self && fn.length > 3) + { + wrappedFn = function (this: unknown, acc, item, index) { return fn.call(this, acc, wrapReactive(item), index, self) }; + } + else + { + wrappedFn = function (this: unknown, acc, item, index) { return fn.call(this, acc, wrapReactive(item), index, self) ;} + } + return (arr[method] as any)(wrappedFn, ...args); +} +function searchProxy(self: unknown[], method: keyof Array, args: unknown[]) +{ + const arr = raw(self) as any; + track(arr, SYMBOLS.ITERATE); + const res = arr[method](...args); + + if ((res === -1 || res === false) && isProxy(args[0])) + { + args[0] = raw(args[0]); + return arr[method](...args); + } + + return res; +} +function noTracking(self: unknown[], method: keyof Array, args: unknown[] = []) +{ + _isTracking = false; + const res = (raw(self) as any)[method].apply(self, args); + _isTracking = true; + return res; +} + +const arraySubstitute = { // <-- is required to allow __proto__ without getting an error + __proto__: null, // <-- Required to remove the object prototype removing the object default functions from the substitution + [Symbol.iterator]() { return iterator(this, Symbol.iterator, item => wrapReactive(item)) }, + concat(...args: unknown[]) { return reactiveReadArray(this).concat(...args.map(x => (Array.isArray(x) ? reactiveReadArray(x) : x))) }, + entries() { return iterator(this, 'entries', (value: [number, unknown]) => { value[1] = wrapReactive(value[1]); return value; }) }, + every(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'every', fn, thisArg, undefined, arguments) }, + filter(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply( this, 'filter', fn, thisArg, v => v.map((item: unknown) => wrapReactive(item)), arguments, ) }, + find(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply( this, 'find', fn, thisArg, item => wrapReactive(item), arguments, ) }, + findIndex(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply(this, 'findIndex', fn, thisArg, undefined, arguments) }, + findLast(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply( this, 'findLast', fn, thisArg, item => wrapReactive(item), arguments) }, + findLastIndex(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply(this, 'findLastIndex', fn, thisArg, undefined, arguments) }, + forEach(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'forEach', fn, thisArg, undefined, arguments) }, + includes(...args: unknown[]) { return searchProxy(this, 'includes', args) }, + indexOf(...args: unknown[]) { return searchProxy(this, 'indexOf', args) }, + join(separator?: string) { return reactiveReadArray(this).join(separator) }, + lastIndexOf(...args: unknown[]) { return searchProxy(this, 'lastIndexOf', args) }, + map(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'map', fn, thisArg, undefined, arguments) }, + pop() { return noTracking(this, 'pop') }, + push(...args: unknown[]) { return noTracking(this, 'push', args) }, + reduce(fn: ( acc: unknown, item: unknown, index: number, array: unknown[], ) => unknown, ...args: unknown[]) { return reduce(this, 'reduce', fn, args) }, + reduceRight(fn: ( acc: unknown, item: unknown, index: number, array: unknown[], ) => unknown, ...args: unknown[]) { return reduce(this, 'reduceRight', fn, args) }, + shift() { return noTracking(this, 'shift') }, + some(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'some', fn, thisArg, undefined, arguments) }, + splice(...args: unknown[]) { return noTracking(this, 'splice', args) }, + toReversed() { return reactiveReadArray(this).toReversed() }, + toSorted(comparer?: (a: unknown, b: unknown) => number) { return reactiveReadArray(this).toSorted(comparer) }, + toSpliced(...args: unknown[]) { return (reactiveReadArray(this).toSpliced as any)(...args) }, + unshift(...args: unknown[]) { return noTracking(this, 'unshift', args) }, + values() { return iterator(this, 'values', item => wrapReactive(item)) }, /* */ +}; + +// Store object to proxy correspondance +const _reactiveCache = new WeakMap(); + +// Store a Weak map of all the tracked object. +// For each object, we have a map of its properties, allowing us to effectively listen to absolutely everything on the object +// For a given property, we have a set of "effect" (function called on value update) +type Dependency = Set<() => void>; +const _tracker = new WeakMap>(); + +function trigger(target: object, key?: string | symbol | null, value?: unknown) +{ + const dependencies = _tracker.get(target); + if(!dependencies) return; + + const run = (dep?: Dependency) => { + dep?.forEach(_defer); + }; + + const isArray = Array.isArray(target); + const arrayIndex = isIntegerKey(key); + + //When the array length is modified, call not only the length and ITERATE dependencies but also the added/removed items dependencies + if(isArray && key === 'length') + { + // Run for 'length' key, SYMBOL.ITERATE and any index key after the new length (for reduction) + dependencies.forEach((v, k: any) => (k === 'length' || k === SYMBOLS.ITERATE || (isIntegerKey(k) && k >= (value as number))) && run(v)); + } + else + { + key !== undefined && run(dependencies.get(key)); + + arrayIndex && run(dependencies.get(SYMBOLS.ITERATE)); + } +} +function track(target: object, key: string | symbol | null) +{ + if(!activeEffect || !_isTracking) return; + + let dependencies = _tracker.get(target); + if(!dependencies) + { + dependencies = new Map(); + _tracker.set(target, dependencies); + } + + let set = dependencies.get(key); + if(!set) + { + set = new Set(); + dependencies.set(key, set); + } + + set.add(activeEffect); + + //if(set) console.log('Tracking %o with key "%s"', target, key, set.size); +} +export type Proxy = T & { + [SYMBOLS.PROXY]?: boolean; + [SYMBOLS.RAW]?: T; +}; +export function isProxy(target: Proxy): boolean +{ + return target[SYMBOLS.PROXY]; +} +export function reactive(obj: T | Proxy): T | Proxy +{ + if((obj as Proxy)[SYMBOLS.PROXY]) + return obj; + + if(_reactiveCache.has(obj)) + return _reactiveCache.get(obj)!; + + const prototype = Object.getPrototypeOf(obj); + const isArray = Array.isArray(obj); + + const proxy = new Proxy(obj, { + get: (target, key, receiver) => { + if(key === SYMBOLS.PROXY) + return true; + else if(key === SYMBOLS.RAW) + return obj; + + if(key in arraySubstitute) + return arraySubstitute[key]!; + + const value = Reflect.get(target, key, receiver); + + track(target, key); + + //If the value is an object, mark it as reactive dynamically + if(value && typeof value === 'object') + return reactive(value as Proxy); + + return value; + }, + set: (target, key, value, receiver) => { + if(key === SYMBOLS.PROXY || key === SYMBOLS.RAW) + return false; + + const result = Reflect.set(target, key, raw(value), receiver); + + trigger(target, key, value); + + return result; + }, + deleteProperty: (target, key) => { + const has = key in target; + const result = Reflect.deleteProperty(target, key); + if(result && has) trigger(target, key); + return result; + }, + has: (target, key) => { + const result = Reflect.has(target, key); + track(target, key); + return result; + }, + ownKeys: (target) => { + const result = Reflect.ownKeys(target); + track(target, SYMBOLS.ITERATE); + return result; + } + }) as Proxy; + + _reactiveCache.set(obj, proxy); + return proxy; +} +export function raw(obj: T): T +{ + return typeof obj === 'object' ? ((obj as Proxy)[SYMBOLS.RAW] as Proxy | undefined) ?? obj : obj; +} +export function reactivity(reactiveProperty: Reactive, effect: (processed: T) => void) +{ + // Function wrapping to keep the context safe and secured. + // Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example) + const secureEffect = () => effect(typeof reactiveProperty === 'function' ? (reactiveProperty as () => T)() : reactiveProperty); + const secureContext = () => { + activeEffect = secureContext; + try { + return secureEffect(); + } finally { + activeEffect = null; + } + }; + + return secureContext(); +} \ No newline at end of file