Feature Builder panel progress

This commit is contained in:
2025-08-11 09:39:41 +02:00
parent 86556ec604
commit 920ce2e1b6
23 changed files with 4924 additions and 4534 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import { z } from "zod/v4";
import characterConfig from './character-config.json';
import { button, fakeA, loading } from "./proses";
import { button, fakeA, input, loading } from "./proses";
import { div, dom, icon, text } from "./dom.util";
import { popper } from "./floating.util";
import { clamp } from "./general.util";
@@ -83,7 +83,6 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
precision: 0,
arts: 0,
},
spells: character.spells ?? [],
speed: false,
defense: {
hardcap: Infinity,
@@ -105,8 +104,16 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
magicinstinct: 0,
},
bonus: {},
resistance: {},//Object.fromEntries(MAIN_STATS.map(e => [e as MainStat, [0, 0]])) as Record<MainStat, [number, number]>,
resistance: {},
initiative: 0,
capacity: 0,
lists: {
action: [],
freeaction: [],
reaction: [],
passive: [],
spells: character.spells,
},
aspect: "",
notes: character.notes ?? "",
});
@@ -120,6 +127,15 @@ export const mainStatTexts: Record<MainStat, string> = {
"charisma": "Charisme",
"psyche": "Psyché",
};
export const mainStatShortTexts: Record<MainStat, string> = {
"strength": "FOR",
"dexterity": "DEX",
"constitution": "CON",
"intelligence": "INT",
"curiosity": "CUR",
"charisma": "CHA",
"psyche": "PSY",
};
export const elementTexts: Record<SpellElement, { class: string, text: string }> = {
fire: { class: 'text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red', text: 'Feu' },
@@ -176,7 +192,7 @@ const stepTexts: Record<number, string> = {
4: 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.'
};
type Property = { value: number | string, operation: "set" | "add" };
type Property = { value: number | string | false, id: string, operation: "set" | "add" };
type PropertySum = { list: Array<Property>, value: number, _dirty: boolean };
export class CharacterBuilder
{
@@ -213,15 +229,10 @@ export class CharacterBuilder
this._result = defaultCompiledCharacter(this._character);
Object.entries(character.leveling).forEach(e => {
const feature = people.options[parseInt(e[0]) as Level][e[1]]!;
feature.effect.map(e => this.apply(e));
});
Object.entries(character.leveling).forEach(e => this.add(people.options[parseInt(e[0]) as Level][e[1]]!));
MAIN_STATS.forEach(stat => {
Object.entries(character.training[stat]).forEach(option => {
config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]!.features?.forEach(this.apply.bind(this));
})
Object.entries(character.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))
});
}
load.remove();
@@ -372,7 +383,7 @@ export class CharacterBuilder
{
if(typeof buffer.list[i]!.value === 'string')
{
if(this._buffer[buffer.list[i]!.value]!._dirty)
if(this._buffer[buffer.list[i]!.value as string]!._dirty)
{
//Put it back in queue since its dependencies haven't been resolved yet
queue.push(property);
@@ -381,9 +392,9 @@ export class CharacterBuilder
else
{
if(buffer.list[i]?.operation === 'add')
sum += this._buffer[buffer.list[i]!.value]!.value;
sum += this._buffer[buffer.list[i]!.value as string]!.value;
else if(buffer.list[i]?.operation === 'set')
sum = this._buffer[buffer.list[i]!.value]!.value;
sum = this._buffer[buffer.list[i]!.value as string]!.value;
}
}
else
@@ -478,31 +489,57 @@ export class CharacterBuilder
{
if(this._character.training[stat].hasOwnProperty(i))
{
config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]?.features?.forEach(this.undo.bind(this));
this.remove(config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]);
delete this._character.training[stat][i as TrainingLevel];
}
}
}
else
{
config.training[stat][level][this._character.training[stat][level]!]?.features?.forEach(this.undo.bind(this));
this.remove(config.training[stat][level][this._character.training[stat][level]!]);
this._character.training[stat][level] = choice;
config.training[stat][level][choice]?.features?.forEach(this.apply.bind(this));
this.add(config.training[stat][level][choice]);
}
}
else
{
this._character.training[stat][level] = choice;
config.training[stat][level][choice]?.features?.forEach(this.apply.bind(this));
this.add(config.training[stat][level][choice]);
}
}
private add(feature?: Feature)
private add(feature?: string)
{
feature?.effect.forEach(this.apply.bind(this));
if(!feature)
return;
config.features[feature]?.effect.forEach(this.apply.bind(this));
}
private remove(feature?: Feature)
private remove(feature?: string)
{
feature?.effect.forEach(this.undo.bind(this));
if(!feature)
return;
config.features[feature]?.effect.forEach(this.undo.bind(this));
}
private choose(id: string, choices: number[])
{
const current = this._character.choices[id];
const [ feature, effect ] = id.split('-');
const option = config.features[feature!]!.effect.find(e => e.id === effect);
if(option?.category === 'choice')
{
if(current !== undefined)
{
current.forEach(e => this.undo(option.options[e]));
}
if(choices.length > 0)
{
choices.forEach(e => this.apply(option.options[e]));
}
this._character.choices[id] = choices;
}
}
private apply(feature?: FeatureItem)
{
@@ -511,30 +548,26 @@ export class CharacterBuilder
switch(feature.category)
{
case "feature":
this._result.features[feature.kind] ??= [];
this._result.features[feature.kind]!.push(feature.text);
return;
case "list":
if(feature.action === 'add' && !this._result[feature.list].includes(feature.item))
this._result[feature.list].push(feature.item);
if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
this._result[feature.list] = this._result[feature.list].filter((e: string) => e !== feature.item);
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter((e: string) => e !== feature.item);
return;
case "value":
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
this._buffer[feature.property]!.list.push({ operation: feature.operation, value: feature.value });
this._buffer[feature.property]!.list.push({ operation: feature.operation, id: feature.id, value: feature.value });
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id]!;
choice.forEach(e => this.apply(feature.options[e]!));
const choice = this._character.choices[feature.id];
if(choice)
choice.forEach(e => this.apply(feature.options[e]!));
return;
default:
@@ -548,29 +581,26 @@ export class CharacterBuilder
switch(feature.category)
{
case "feature":
this._result.features[feature.kind] = this._result.features[feature.kind]!.filter(e => e !== feature.text);
return;
case "list":
if(feature.action === 'remove' && !this._result[feature.list].includes(feature.item))
this._result[feature.list].push(feature.item);
if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
this._result[feature.list] = this._result[feature.list].filter(e => e !== feature.item);
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter(e => e !== feature.item);
return;
case "value":
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
this._buffer[feature.property]!.list.splice(this._buffer[feature.property]!.list.findIndex(e => e.operation === feature.operation && e.value === feature.value), 1);
this._buffer[feature.property]!.list.splice(this._buffer[feature.property]!.list.findIndex(e => e.id === feature.id), 1);
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id]!;
choice.forEach(e => this.undo(feature.options[e]!));
delete this._character.choices[feature.id];
const choice = this._character.choices[feature.id];
if(choice)
choice.forEach(e => this.undo(feature.options[e]!));
return;
default:
@@ -599,14 +629,12 @@ class PeoplePicker implements BuilderTab
{
this._builder = builder;
this._nameInput = dom("input", { class: `mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, listeners: {
input: (e: Event) => {
this._builder.character.name = this._nameInput.value ?? '';
this._nameInput = input('text', {
input: (value) => {
this._builder.character.name = value ?? '';
document.title = `d[any] - Edition de ${this._builder.character.name || 'nouveau personnage'}`;
}
}});
});
this._visibilityInput = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": "unckecked" }, listeners: {
@@ -709,7 +737,7 @@ class LevelPicker implements BuilderTab
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px] relative", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling[level[0] as any as Level] === j }], listeners: { click: e => {
this._builder.toggleLevelOption(parseInt(level[0]) as Level, j);
this.update();
}}}, [ dom('span', { class: "text-wrap whitespace-pre", text: option.description }), option.effect.some(e => e.category === 'choice') ? div('absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', [ icon('radix-icons:gear') ]) : undefined ])))
}}}, [ dom('span', { class: "text-wrap whitespace-pre", text: config.features[option]!.description }), config.features[option]!.effect.some(e => e.category === 'choice') ? div('absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', [ icon('radix-icons:gear') ]) : undefined ])))
]);
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [
@@ -788,7 +816,7 @@ class TrainingPicker implements BuilderTab
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => {
this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j);
this.update();
}}}, [ markdownUtil(option.description.map(e => e.text).join('\n'), undefined, { tags: { a: fakeA } }) ])))
}}}, [ markdownUtil(config.features[option]!.description, undefined, { tags: { a: fakeA } }) ])))
])
}
this._builder = builder;

View File

@@ -704,7 +704,7 @@ export class Editor
{
e.preventDefault();
const close = contextmenu(e.clientX, e.clientY, [
const { close } = contextmenu(e.clientX, e.clientY, [
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.add("markdown", item); close() }} }, [icon('radix-icons:plus'), text('Ajouter')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-100 dark:text-dark-100', listeners: { click: (e) => { this.rename(item); close() }} }, [icon('radix-icons:input'), text('Renommer')]),
dom('div', { class: 'hover:bg-light-35 dark:hover:bg-dark-35 px-2 gap-2 flex py-1 items-center cursor-pointer text-light-red dark:text-dark-red', listeners: { click: (e) => { close(); confirm(`Confirmer la suppression de ${item.title}${item.children ? ' et de ses enfants' : ''} ?`).then(e => { if(e) this.remove(item)}) }} }, [icon('radix-icons:trash'), text('Supprimer')]),

View File

@@ -11,7 +11,7 @@ type Listener<K extends keyof HTMLElementEventMap> = | ((ev: HTMLElementEventMap
export interface NodeProperties
{
attributes?: Record<string, string | undefined | boolean>;
attributes?: Record<string, string | undefined | boolean | number>;
text?: string;
class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
@@ -30,7 +30,7 @@ export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?:
if(properties?.attributes)
for(const [k, v] of Object.entries(properties.attributes))
if(typeof v === 'string') element.setAttribute(k, v);
if(typeof v === 'string' || typeof v === 'number') element.setAttribute(k, v.toString(10));
else if(typeof v === 'boolean') element.toggleAttribute(k, v);
if(properties?.text)
@@ -113,20 +113,24 @@ export interface IconProperties
style?: Record<string, string | undefined> | string;
class?: Class;
}
const iconCache: Map<IconProperties & { name: string }, HTMLElement> = new Map();
const iconCache: Map<string, HTMLElement> = new Map();
export function icon(name: string, properties?: IconProperties): HTMLElement
{
const key = { ...properties, name };
let el;
if(iconCache.has(key))
return iconCache.get(key)!.cloneNode() as HTMLElement;
if(iconCache.has(name))
el = iconCache.get(name)!.cloneNode() as HTMLElement;
else
{
el = document.createElement('iconify-icon');
const el = document.createElement('iconify-icon');
if(!iconExists(name))
loadIcon(name);
el.setAttribute('icon', name);
if(!iconExists(name))
loadIcon(name);
el.setAttribute('icon', name);
iconCache.set(name, el.cloneNode() as HTMLElement);
}
properties?.mode && el.setAttribute('mode', properties?.mode.toString());
properties?.inline && el.toggleAttribute('inline', properties?.inline);
@@ -150,7 +154,6 @@ export function icon(name: string, properties?: IconProperties): HTMLElement
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) el.attributeStyleMap.set(k, v);
}
iconCache.set(key, el.cloneNode() as HTMLElement);
return el;
}

View File

@@ -9,13 +9,12 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { IterMode, Tree } from '@lezer/common';
import { tags } from '@lezer/highlight';
import { dom } from './dom.util';
const External = Annotation.define<boolean>();
const Hidden = Decoration.mark({ class: 'hidden' });
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
const TagTag = tags.special(tags.content);
const intersects = (a: {
from: number;
to: number;
@@ -36,7 +35,6 @@ const highlight = HighlightStyle.define([
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.keyword, color: "#708" },
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
]);
class Decorator
@@ -111,29 +109,7 @@ export class MarkdownEditor
this.view = new EditorView({
extensions: [
markdown({
base: markdownLanguage,
extensions: {
defineNodes: [
{ name: "Tag", style: TagTag },
{ name: "TagMark", style: tags.processingInstruction }
],
parseInline: [{
name: "Tag",
parse(cx, next, pos) {
if (next != 35 || cx.char(pos + 1) == 35) return -1;
let elts = [cx.elt("TagMark", pos, pos + 1)];
for (let i = pos + 1; i < cx.end; i++) {
let next = cx.char(i);
if (next == 35)
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
if (next == 92)
elts.push(cx.elt("Escape", i, i++ + 2));
if (next == 32 || next == 9 || next == 10 || next == 13) break;
}
return -1
}
}],
}
base: markdownLanguage
}),
history(),
search(),

318
shared/feature.util.ts Normal file
View File

@@ -0,0 +1,318 @@
import type { Ability, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, TrainingOption } from "~/types/character";
import { div, dom, icon, text, type NodeChildren } from "./dom.util";
import { MarkdownEditor } from "./editor.util";
import { button, combobox, fakeA, input, numberpicker, select } from "./proses";
import { popper, tooltip } from "./floating.util";
import { mainStatShortTexts, mainStatTexts } from "./character.util";
import config from "#shared/character-config.json";
import { getID, ID_SIZE } from "./general.util";
import renderMarkdown from "./markdown.util";
export class FeatureEditor
{
private _container: HTMLDivElement;
private _success?: Function;
private _failure?: Function;
private _feature?: Feature;
private _idInput: HTMLInputElement;
private _table: HTMLDivElement;
constructor()
{
this._idInput = dom("input", { attributes: { 'disabled': true }, class: `mx-4 text-light-70 dark:text-dark-70 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-25 dark:bg-dark-25 border-light-30 dark:border-dark-30` });
this._table = div('grid grid-cols-2 gap-4 px-2');
this._container = dom('div', { attributes: { 'data-state': 'inactive' }, class: '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-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [
div('flex flex-row justify-between items-center', [
tooltip(button(icon('radix-icons:check', { width: 20, height: 20 }), () => {
this._success!(this._feature);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Valider', 'left'),
dom('label', { class: 'flex justify-center items-center my-2' }, [
dom('span', { class: 'pb-1 md:p-0', text: "ID" }),
this._idInput
]),
tooltip(button(icon('radix-icons:cross-1', { width: 20, height: 20 }), () => {
this._failure!(this._feature);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Annuler', 'left'),
]),
dom('span', { class: 'flex flex-col justify-start items-start my-2 gap-4' }, [
div('flex w-full items-center justify-between', [
dom('span', { class: 'pb-1 md:p-0', text: "Description" }),
tooltip(button(icon('radix-icons:clipboard', { width: 20, height: 20 }), () => {
MarkdownEditor.singleton.content = this._feature?.effect.map(e => textFromEffect(e)).join('\n') ?? this._feature?.description ?? MarkdownEditor.singleton.content;
}, 'p-1'), 'Description automatique', 'left'),
]),
div('p-1 border border-light-40 dark:border-dark-40 w-full bg-light-25 dark:bg-dark-25 min-h-48 max-h-[32rem]', [ MarkdownEditor.singleton.dom ]),
]),
div('flex flex-col gap-2 w-full', [
div('flex flex-row justify-between', [
dom('h3', { class: 'text-lg font-bold', text: 'Effets' }),
tooltip(button(icon('radix-icons:plus', { width: 20, height: 20 }), () => {
this._table.appendChild(this._edit({ id: getID(ID_SIZE) }));
}, 'p-1'), 'Ajouter', 'left'),
]),
this._table,
])
]);
}
edit(feature: Feature): Promise<Feature>
{
return new Promise((success, failure) => {
this._success = success;
this._failure = failure;
this._feature = JSON.parse(JSON.stringify(feature)) as Feature;
this._table.replaceChildren(...this._feature.effect.map(this._renderEffect.bind(this)));
this._idInput.value = this._feature.id;
MarkdownEditor.singleton.onChange = (e) => this._feature!.description = e;
MarkdownEditor.singleton.content = this._feature.description;
});
}
private _renderEffect(effect: FeatureItem): HTMLDivElement
{
const content = div('border border-light-30 dark:border-dark-30 col-span-1', [ div('flex justify-between items-stretch', [
div('px-4 flex items-center h-full', [ renderMarkdown(textFromEffect(effect), undefined, { tags: { a: fakeA } }) ]),
div('flex', [ tooltip(button(icon('radix-icons:pencil-1'), () => {
this._table.replaceChild(this._edit(effect), content);
}, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Modifier", "bottom"), tooltip(button(icon('radix-icons:trash'), () => {
this._feature!.effect = this._feature!.effect.filter(e => e.id !== effect.id);
content.remove();
}, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Supprimer", "bottom") ])
]) ]);
return content;
}
private _edit(effect: FeatureItem): HTMLDivElement
{
const match = (effect: FeatureItem): Partial<FeatureItem> | undefined => {
switch(effect.category)
{
case 'value':
return choices.find(e => e.value.category === 'value' && e.value.property === effect.property)?.value;
/* case 'choice':
return choices.find(e => e.value.category === 'choice' && e.value. === effect.property); */
case 'list':
return choices.find(e => e.value.category === 'list' && e.value.list === effect.list)?.value;
}
};
const approve = () => {
const idx = this._feature!.effect.findIndex(e => e.id === buffer.id);
if(idx === -1)
this._feature!.effect.push(buffer);
else
this._feature!.effect[idx] = buffer;
this._table.replaceChild(this._renderEffect(buffer), content);
}, reject = () => {
const idx = this._feature!.effect.findIndex(e => e.id === buffer.id);
if(idx === -1)
content.remove();
else
this._table.replaceChild(this._renderEffect(effect), content);
}
let buffer = JSON.parse(JSON.stringify(effect)) as FeatureItem;
const redraw = () => {
let top: NodeChildren = [], bottom: NodeChildren = [];
switch(buffer.category)
{
case 'value':
const summaryText = text(textFromEffect(buffer));
top = [
select([ { text: '+', value: 'add' }, ['speed', 'capacity'].includes(buffer.property) ? { text: '=', value: 'set' } : undefined ], { defaultValue: buffer.operation, change: (value) => { (buffer as Extract<FeatureEffect, { category: "value" }>).operation = value as 'add' | 'set'; summaryText.textContent = textFromEffect(buffer); }, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' } }),
typeof buffer.value === 'number' ? numberpicker({ defaultValue: buffer.value, input: (value) => { (buffer as Extract<FeatureEffect, { category: "value" }>).value = value; summaryText.textContent = textFromEffect(buffer); }, class: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' }) : select<`modifier/${MainStat}` | false>([...Object.entries(mainStatShortTexts).map(e => ({ text: 'Mod. de ' + e[1], value: `modifier/${e[0]}` as `modifier/${MainStat}` })), buffer.operation === 'add' ? undefined : { text: 'Interdit', value: false }], { class: { container: 'w-[160px] bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' }, defaultValue: buffer.value, change: (value) => { (buffer as Extract<FeatureEffect, { category: "value" }>).value = value; summaryText.textContent = textFromEffect(buffer); } }),
button(icon('radix-icons:update'), () => {
(buffer as Extract<FeatureEffect, { category: "value" }>).value = (typeof (buffer as Extract<FeatureEffect, { category: "value" }>).value === 'number' ? '' as any as false : 0);
const element = redraw();
this._table.replaceChild(element, content);
content = element;
summaryText.textContent = textFromEffect(buffer);
}, 'px-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'),
];
bottom = [summaryText];
break;
case 'list':
if(buffer.action === 'add')
{
if(buffer.list === 'spells')
{
bottom = [ combobox(config.spells.map(e => ({ text: e.name, value: e.id })), { defaultValue: buffer.item, change: (value) => (buffer as Extract<FeatureEffect, { category: "list" }>).item = value, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px]' } }) ];
}
else
{
const editor = new MarkdownEditor();
editor.content = buffer.item;
editor.onChange = (item) => (buffer as Extract<FeatureEffect, { category: "list" }>).item = item;
bottom = [ div('px-4 py-1', [ editor.dom ]) ];
}
}
top = [ select([ { text: 'Ajouter', value: 'add' }, { text: 'Supprimer', value: 'remove' } ], { defaultValue: buffer.action, change: (value) => (buffer as Extract<FeatureEffect, { category: "list" }>).action = value as 'add' | 'remove', class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px h-[36px] w-32' } }) ];
break;
default: break;
}
return div('border border-light-30 dark:border-dark-30 col-span-1 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row', [
combobox(choices, { defaultValue: match(buffer), class: { container: 'bg-light-25 dark:bg-dark-25 w-[250px] -m-px h-[36px]' }, change: (e) => {
buffer = { id: buffer.id, ...e } as FeatureItem;
const element = redraw();
this._table.replaceChild(element, content);
content = element;
} }),
...top,
]),
div('flex', [ tooltip(button(icon('radix-icons:check'), approve, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Valider", "bottom"), tooltip(button(icon('radix-icons:cross-1'), reject, 'p-2 -m-px border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Annuler", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-auto', bottom) ]);
}
let content = redraw();
return content;
}
get container()
{
return this._container;
}
}
const choices: Array<{ text: string, value: Partial<FeatureItem> }> = [
{ text: 'PV max', value: { category: 'value', property: 'health', operation: 'add', value: 0 }, },
{ text: 'Mana max', value: { category: 'value', property: 'mana', operation: 'add', value: 0 }, },
{ text: 'Nombre de sorts maitrisés', value: { category: 'value', property: 'spellslots', operation: 'add', value: 0 }, },
{ text: 'Nombre d\'œuvres maitrisés', value: { category: 'value', property: 'artslots', operation: 'add', value: 0 }, },
{ text: 'Vitesse de course', value: { category: 'value', property: 'speed', operation: 'add', value: 0 }, },
{ text: 'Poids max', value: { category: 'value', property: 'capacity', operation: 'add', value: 0 }, },
{ text: 'Initiative', value: { category: 'value', property: 'initiative', operation: 'add', value: 0 }, },
{ text: 'Points d\'entrainement', value: { category: 'value', property: 'training', operation: 'add', value: 0 }, },
{ text: 'Points de compétence', value: { category: 'value', property: 'ability', operation: 'add', value: 0 }, },
{ text: 'Sort bonus', value: { category: 'list', list: 'spells', action: 'add' }, },
{ text: 'Action', value: { category: 'list', list: 'action', action: 'add' }, },
{ text: 'Réaction', value: { category: 'list', list: 'reaction', action: 'add' }, },
{ text: 'Action libre', value: { category: 'list', list: 'freeaction', action: 'add' }, },
{ text: 'Passif', value: { category: 'list', list: 'passive', action: 'add' }, },
{ text: 'Choix', value: { category: 'choice', options: [] }, },
];
function textFromEffect(effect: FeatureItem)
{
if(effect.category === 'value')
{
switch(effect.property)
{
case 'health':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' PV max.' } }) : textFromValue(effect.value, { prefix: { truely: 'PV max égal à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (PV = interdit).' });
case 'mana':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' mana max.' } }) : textFromValue(effect.value, { prefix: { truely: 'Mana max égal à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Mana = interdit).' });
case 'spellslots':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' sort(s) maitrisé(s).' } }) : textFromValue(effect.value, { prefix: { truely: 'Sorts maitrisés fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Sorts = interdit).' });
case 'artslots':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' œuvre(s) maitrisé(s).' } }) : textFromValue(effect.value, { prefix: { truely: 'Œuvres maitrisés fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Œuvres = interdit).' });
case 'speed':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' case(s) de course.' }, falsely: '+0 cases de course' }) : textFromValue(effect.value, { prefix: { truely: 'Vitesse de course de ' }, suffix: { truely: ' case(s).' }, falsely: 'Déplacement impossible.' });
case 'capacity':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' unité(s) d\'quipement.' } }) : textFromValue(effect.value, { prefix: { truely: 'Capacité d\'equipement fixé à ' }, suffix: { truely: ' unité(s).' }, falsely: 'Impossible de posséder du materiel.' });
case 'initiative':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' à l\'itiniative.' } }) : textFromValue(effect.value, { prefix: { truely: 'Initiative fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Initiative = interdit).' });
case 'training':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' point(s) d\'entrainement.' } }) : `Opération interdite (Entrainement fixe).`;
case 'ability':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' point(s) de compétence.' } }) : `Opération interdite (Compétences fixe).`;
default: break;
}
const splited = effect.property.split('/');
switch(splited[0])
{
case 'spellranks':
return '';
case 'defense':
switch(splited[1])
{
case 'hardcap':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Défense max ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Défense max fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Hardcap = interdit).' });
case 'static':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Base de défense ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Base de défense fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Static = interdit).' });
case 'activeparry':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Parade active ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Parade active fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Active parry = interdit).' });
case 'activedodge':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Esquive active ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Esquive active fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Active dodge = interdit).' });
case 'passiveparry':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Parade passive ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Parade passive fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Passive parry = interdit).' });
case 'passivedodge':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Esquive passive ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Esquive passive fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Passive dodge = interdit).' });
default: return 'Défense inconnue.';
}
case 'mastery':
switch(splited[1])
{
case 'strength':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (for.) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (for.) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise for = interdit).' });
case 'dexterity':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (dex.) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des armes (dex.) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise dex = interdit).' });
case 'shield':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des boucliers ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des boucliers fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise boucliers = interdit).' });
case 'armor':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Maitrise des armure ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Maitrise des armure fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise armure = interdit).' });
case 'multiattack':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Attaque multiple ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Attaque multiple fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Attaque multiple = interdit).' });
case 'magicpower':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Puissance) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Puissance) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise puissance = interdit).' });
case 'magicspeed':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Rapidité) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Rapidité) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise rapidité = interdit).' });
case 'magicelement':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Elements) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Elements) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise elements = interdit).' });
case 'magicinstinct':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Instinct) ', positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: 'Arbre de magie (Instinct) fixée à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Maitrise instinct = interdit).' });
default: return 'Maitrise inconnue.';
}
/* case 'resistance':
return splited[1] ? config.resistances[splited[1] as string].name : 'résistance inconnue'; */
case 'abilities':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: `${config.abilities[splited[1] as Ability].name} `, positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: `${config.abilities[splited[1] as Ability].name} fixé à ` }, suffix: { truely: '.' }, falsely: `Echec automatique de ${`${config.abilities[splited[1] as Ability].name}.`}` });
case 'modifier':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+' }, suffix: { truely: ` au mod. de ${mainStatTexts[splited[1] as MainStat]}.` } }) : textFromValue(effect.value, { prefix: { truely: `Mod. de ${mainStatTexts[splited[1] as MainStat]} fixé à ` }, suffix: { truely: '.' }, falsely: `Opération interdite (Mod. de ${mainStatShortTexts[splited[1] as MainStat]} = interdit).` });
default: break;
}
return `Inconnu ("${effect.property}")`;
}
else if(effect.category === 'list')
{
switch(effect.list)
{
case 'action':
case 'reaction':
case 'freeaction':
case 'passive':
return effect.action === 'add' ? effect.item : 'Suppression d\'effet.';
case 'spells':
return effect.action === 'add' ? `Maitrise du sort "${config.spells.find(e => e.id === effect.item) ?? 'Sort inconnu'}".` : `Perte de maitrise du sort "${config.spells.find(e => e.id === effect.item) ?? 'Sort inconnu'}".`;
}
}
else if(effect.category === 'choice')
{
return `Choix (WIP)`;
}
else
{
return `Inconnu`;
}
}
function textFromValue(value: `modifier/${MainStat}` | number | false, settings?: {
prefix?: { text?: string, positive?: string, negative?: string, truely?: string },
suffix?: { text?: string, positive?: string, negative?: string, truely?: string },
falsely?: string
})
{
if(typeof value === 'string')
return `${settings?.prefix?.truely?.replaceAll('(s)', 's') ?? ''}${settings?.prefix?.text?.replaceAll('(s)', 's') ?? ''}${mainStatShortTexts[value.split('/')[1] as MainStat] ?? 'inconnu'}${settings?.suffix?.text?.replaceAll('(s)', 's') ?? ''}${settings?.suffix?.truely?.replaceAll('(s)', 's') ?? ''}`;
else if(value === false)
return settings?.falsely ?? '0';
else if(value >= 0)
return `${settings?.prefix?.truely?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}${settings?.prefix?.positive?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}${value.toString(10)}${settings?.suffix?.positive?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}${settings?.suffix?.truely?.replaceAll('(s)', value > 1 ? 's' : '') ?? ''}`;
else
return `${settings?.prefix?.truely?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}${settings?.prefix?.negative?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}${value.toString(10)}${settings?.suffix?.negative?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}${settings?.suffix?.truely?.replaceAll('(s)', value < -1 ? 's' : '') ?? ''}`;
}

View File

@@ -8,12 +8,13 @@ export interface ContextProperties
offset?: number;
arrow?: boolean;
class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
viewport?: HTMLElement;
}
export interface PopperProperties extends ContextProperties
{
content?: NodeChildren;
delay?: number;
viewport?: HTMLElement;
onShow?: (element: HTMLDivElement) => boolean | void;
onHide?: (element: HTMLDivElement) => boolean | void;
@@ -36,7 +37,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
{
let shown = false, timeout: Timer;
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const content = dom('div', { class: ['fixed hidden', properties?.class], attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]);
const content = dom('div', { class: ['fixed hidden', properties?.class], style: properties?.style, attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]);
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
function update()
@@ -151,7 +152,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
return container;
}
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties): () => void
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties & { blur?: () => void })
{
const virtual = {
getBoundingClientRect() {
@@ -167,9 +168,10 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
};
},
};
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class] }, content);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class], style: properties?.style }, content);
function update()
{
@@ -178,9 +180,10 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.flip(),
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined,
FloatingUI.shift({ rootBoundary: rect }),
properties?.offset ? FloatingUI.shift({ padding: properties?.offset, rootBoundary: rect }) : undefined,
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
FloatingUI.hide({ rootBoundary: rect }),
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(container.style, {
@@ -242,16 +245,27 @@ export function contextmenu(x: number, y: number, content: NodeChildren, propert
container.remove();
stop();
properties?.blur && properties.blur();
}
return close;
return { close, container, content };
}
export function tooltip(container: HTMLElement, txt: string, placement: FloatingUI.Placement, delay?: number): HTMLElement
{
return popper(container, {
arrow: true,
offset: 8,
delay: delay,
content: [ text(txt) ],
placement: placement,
class: "fixed hidden TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50"
});
}
export function modal(content: NodeChildren, properties?: ModalProperties)
export function fullblocker(content: NodeChildren, properties?: ModalProperties)
{
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } });
const _closer = properties?.priority ? undefined : dom('span', { class: 'absolute top-4 right-4', text: '×', listeners: { click: () => _modal.remove() } });
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content)])
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]);
teleport.appendChild(_modal);
@@ -259,6 +273,10 @@ export function modal(content: NodeChildren, properties?: ModalProperties)
close: () => _modal.remove(),
}
}
export function modal(content: NodeChildren, properties?: ModalProperties)
{
return fullblocker([ dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content) ], properties);
}
export function confirm(title: string): Promise<boolean>
{

View File

@@ -7,7 +7,7 @@ export function unifySlug(slug: string | string[]): string
export function getID(length: number)
{
for (var id = [], i = 0; i < length; i++)
id.push((16 * Math.random() | 0).toString(16));
id.push((36 * Math.random() | 0).toString(36));
return id.join("");
}
export function group<

View File

@@ -1,11 +1,11 @@
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses } from "#shared/dom.util";
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses, text, div } from "#shared/dom.util";
import { parseURL } from 'ufo';
import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util";
import { contextmenu, popper } from "#shared/floating.util";
import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util";
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
import { unifySlug } from "#shared/general.util";
import { clamp, unifySlug } from "#shared/general.util";
export type CustomProse = (properties: any, children: NodeChildren) => Node;
export type Prose = { class: string } | { custom: CustomProse };
@@ -230,4 +230,194 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
return dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: onClick } }, [ content ]);
}
export function select<T extends NonNullable<any>>(options: Array<string | undefined> | Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
const textFromValue = (value?: T): string => {
if(!value)
return '';
const found = options.find(e => (e as { value: string } | undefined)?.value === value || e === value);
if(!found)
return '';
return (found as { text: string } | undefined)?.text ?? found as string;
};
let close: Function | undefined;
let disabled = settings?.disabled ?? false;
const textValue = text(textFromValue(settings?.defaultValue));
const optionElements = options.map(e => {
if(e === undefined)
return;
return dom('div', { listeners: { click: () => {
let text, value;
if(typeof e === 'string')
{
text = value = e;
}
else
{
text = e.text;
value = e.value;
}
textValue.textContent = text;
settings?.change && settings?.change(value);
close && close();
} }, class: ['hover:bg-light-30 dark:hover:bg-dark-30 text-light-70 dark:text-dark-70 hover:text-light-100 dark:hover:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text((e as { text: string } | undefined)?.text ?? e as string) ]);
});
const select = dom('div', { listeners: { click: () => {
if(disabled)
return;
const box = select.getBoundingClientRect();
close = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` } }).close;
} }, class: ['mx-4 inline-flex items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1 bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark: data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ dom('span', {}, [ textValue ]), icon('radix-icons:caret-down') ]);
Object.defineProperty(select, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
select.toggleAttribute('data-disabled', disabled);
},
})
return select;
}
export function combobox<T extends NonNullable<any>>(options: Array<string | undefined> | Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
const textFromValue = (value?: T): string => {
if(!value)
return '';
const found = options.find(e => (e as { value: string } | undefined)?.value === value || e === value);
if(!found)
return '';
return (found as { text: string } | undefined)?.text ?? found as string;
};
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true;
const show = () => {
if(disabled || (context && context.container.parentElement))
return;
const box = container.getBoundingClientRect();
context = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements.map(e => e?.dom) : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` }, blur: () => { if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red') } });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
if(!context || !context.container.parentElement)
return;
context.close();
if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red');
};
let disabled = settings?.disabled ?? false;
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
return { item: e, dom: dom('div', { listeners: { click: () => {
let text, value;
if(typeof e === 'string')
{
text = value = e;
}
else
{
text = e.text;
value = e.value;
}
select.value = text;
settings?.change && settings?.change(value);
selected = true;
hide();
} }, class: ['hover:bg-light-30 dark:hover:bg-dark-30 text-light-70 dark:text-dark-70 hover:text-light-100 dark:hover:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text((e as { text: string } | undefined)?.text ?? e as string) ]) };
});
const select = dom('input', { listeners: { focus: show, input: () => {
context && context?.container.replaceChildren(...optionElements.filter(e => {
if(e === undefined)
return false;
if(typeof e.item === 'string')
return (e.item as string).toLowerCase().includes(select.value.toLowerCase());
return e.item.text.toLowerCase().includes(select.value.toLowerCase());
}).map(e => e!.dom));
selected = false;
if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red')
} }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20' });
select.value = textFromValue(settings?.defaultValue);
const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]);
Object.defineProperty(container, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
container.toggleAttribute('data-disabled', disabled);
select.toggleAttribute('disabled', disabled);
},
})
return container;
}
export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void, focus?: () => void, blur?: () => void, class?: Class, disabled?: boolean }): HTMLInputElement
{
const input = dom("input", { attributes: { disabled: settings?.disabled }, class: [`mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, settings?.class], listeners: {
input: () => settings?.input && settings.input(input.value),
change: () => settings?.change && settings.change(input.value),
focus: () => settings?.focus,
blur: () => settings?.blur,
}})
if(settings?.defaultValue !== undefined) input.value = settings.defaultValue;
return input;
}
export function numberpicker(settings?: { defaultValue?: number, change?: (value: number) => void, input?: (value: number) => void, focus?: () => void, blur?: () => void, class?: Class, min?: number, max?: number, disabled?: boolean }): HTMLInputElement
{
let storedValue = settings?.defaultValue ?? 0;
const validateAndChange = (value: number) => {
if(isNaN(value))
field.value = '';
else
{
value = clamp(value, settings?.min ?? -Infinity, settings?.max ?? Infinity);
field.value = value.toString(10);
if(storedValue !== value)
{
storedValue = value;
return true;
}
}
return false;
}
const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [`w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, settings?.class], listeners: {
input: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue),
keydown: (e: KeyboardEvent) => {
switch(e.key)
{
case "ArrowUp":
validateAndChange(storedValue + (e.shiftKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "ArrowDown":
validateAndChange(storedValue - (e.shiftKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "PageUp":
settings?.max && validateAndChange(settings.max) && settings?.input && settings.input(storedValue);
break;
case "PageDown":
settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue);
break;
default:
return;
}
},
change: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.change && settings.change(storedValue),
focus: () => settings?.focus && settings.focus(),
blur: () => settings?.blur && settings.blur(),
}});
if(settings?.defaultValue) field.value = storedValue.toString(10);
return field;
}