Compare commits

...

2 Commits

Author SHA1 Message Date
Clément Pons 78a101b79d Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2025-12-16 15:41:35 +01:00
Clément Pons 49691feeee Rework reactivity for array listening 2025-12-16 15:41:12 +01:00
12 changed files with 562 additions and 413 deletions

BIN
db.sqlite

Binary file not shown.

View File

@ -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<T extends keyof CharacterVariables>(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()
{
@ -1328,7 +1318,7 @@ export class CharacterSheet
});
})
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) => {
@ -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<ItemState>)?.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<ItemState>)?.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<ItemState>)?.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);
}

View File

@ -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<RouteMapGeneric, string>)
{

View File

@ -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<Reactive<Node>> | undefined;
export type Class = Reactive<string | Array<Class> | Record<string, boolean> | undefined>;
export type Class = string | Array<Class> | Record<string, boolean> | undefined;
type Listener<K extends keyof HTMLElementEventMap> = | ((this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any;
} | undefined;
export type Reactive<T> = T | (() => T);
export interface DOMList<T> extends Array<T>{
render(redraw?: boolean): void;
};
@ -21,288 +20,166 @@ export interface NodeProperties
{
attributes?: Record<string, Reactive<string | undefined | boolean | number>>;
text?: Reactive<string | number | Text>;
class?: Class;
class?: Reactive<Class>;
style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
listeners?: {
[K in keyof HTMLElementEventMap]?: Listener<K>
};
}
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<string | symbol, Set<() => void>>>();
function trigger<T extends {}>(target: T, key: string | symbol)
{
const dependencies = _tracker.get(target)
if(!dependencies) return;
const set = dependencies.get(key);
set?.forEach(e => e());
}
function track<T extends {}>(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<T extends {}>(obj: T): T
{
if(_reactiveCache.has(obj))
return _reactiveCache.get(obj)!;
const proxy = new Proxy<T>(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<T>(reactiveProperty: Reactive<T>, 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<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T] & { update?: (recursive: boolean) => void };
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList<U>, update?: (recursive: boolean) => void };
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList<U>, update?: (recursive: boolean) => void }
export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T];
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap[T] & { array?: DOMList<U> };
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap[T] & { array?: DOMList<U> }
{
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U>, update: (recursive: boolean) => void };
let setup = true, updating = false;
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U> };
const _cache = new Map<U, Node>();
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<U>;
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<typeof key>;
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<typeof key>;
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<U extends any>(cls?: Class, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array: DOMList<U>, update?: (recursive: boolean) => void }
export function div<U extends any>(cls?: Class, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array?: DOMList<U>, update?: (recursive: boolean) => void }
export function div(cls?: Reactive<Class>, children?: NodeChildren): HTMLElementTagNameMap['div']
export function div<U extends any>(cls?: Reactive<Class>, children?: { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap['div'] & { array: DOMList<U> }
export function div<U extends any>(cls?: Reactive<Class>, children?: NodeChildren | { render: (data: U) => Node, list?: Reactive<Array<U>> }): HTMLElementTagNameMap['div'] & { array?: DOMList<U> }
{
//@ts-expect-error
//@ts-expect-error wtf is wrong here ???
return dom("div", { class: cls }, children);
}
export function span(cls?: Class, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void }
export function span(cls?: Reactive<Class>, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void }
{
return dom("span", { class: cls, text: text });
}
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: SVGElement[]): SVGElementTagNameMap[K]
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Array<Reactive<SVGElement>>): 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<string | number>): 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<Record<string, string | undefined | boolean | number> | 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();

View File

@ -21,8 +21,9 @@ export function dom<K extends keyof HTMLElementTagNameMap>(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)

View File

@ -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)
{

View File

@ -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;

View File

@ -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
{

View File

@ -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"] ?? '' : '') : '';
}

View File

@ -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,9 +64,7 @@ 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;
}));

View File

@ -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
{

304
shared/reactive.ts Normal file
View File

@ -0,0 +1,304 @@
export type Reactive<T> = 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<T>(array: T[]): T[]
{
const _raw = raw(array)
if (_raw === array) return _raw;
track(_raw, SYMBOLS.ITERATE);
return _raw.map(wrapReactive);
}
function shallowReadArray<T>(arr: T[]): T[]
{
track((arr = raw(arr)), SYMBOLS.ITERATE);
return arr;
}
function iterator(self: unknown[], method: keyof Array<unknown>, wrapValue: (value: any) => unknown)
{
const arr = shallowReadArray(self);
const iter = (arr[method] as any)() as IterableIterator<unknown> & {
_next: IterableIterator<unknown>['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<object>) : obj;
}
const arrayProto = Array.prototype
function apply(self: unknown[], method: keyof Array<any>, 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<any>, 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<any>, 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<any>, args: unknown[] = [])
{
_isTracking = false;
const res = (raw(self) as any)[method].apply(self, args);
_isTracking = true;
return res;
}
const arraySubstitute = <any>{ // <-- <any> 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<object, Map<string | symbol | null, Dependency>>();
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> = T & {
[SYMBOLS.PROXY]?: boolean;
[SYMBOLS.RAW]?: T;
};
export function isProxy(target: Proxy<any>): boolean
{
return target[SYMBOLS.PROXY];
}
export function reactive<T extends object>(obj: T | Proxy<T>): T | Proxy<T>
{
if((obj as Proxy<T>)[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<T>(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<object>);
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<T>;
_reactiveCache.set(obj, proxy);
return proxy;
}
export function raw<T>(obj: T): T
{
return typeof obj === 'object' ? ((obj as Proxy<T>)[SYMBOLS.RAW] as Proxy<T> | undefined) ?? obj : obj;
}
export function reactivity<T>(reactiveProperty: Reactive<T>, 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();
}