Aspect and Spell editor, multiselect component.

This commit is contained in:
Peaceultime 2025-08-26 00:17:08 +02:00
parent 69ee62c08e
commit 893247e1eb
8 changed files with 1971 additions and 549 deletions

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter,
import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json';
import { fakeA } from "#shared/proses";
import { button, input, loading, numberpicker, select } from "#shared/components.util";
import { button, input, loading, numberpicker, select, toggle } from "#shared/components.util";
import { div, dom, icon, mergeClasses, text, type Class } from "#shared/dom.util";
import { followermenu, popper } from "#shared/floating.util";
import { clamp } from "#shared/general.util";
@ -17,6 +17,7 @@ export const TRAINING_LEVELS = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] as const;
export const SPELL_TYPES = ["precision","knowledge","instinct","arts"] as const;
export const CATEGORIES = ["action","reaction","freeaction","misc"] as const;
export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const;
export const ALIGNMENTS: Alignment[] = [{ kindness: 'good', loyalty: 'loyal' }, { kindness: 'good', loyalty: 'neutral' }, { kindness: 'good', loyalty: 'chaotic' }, { kindness: 'neutral', loyalty: 'loyal' }, { kindness: 'neutral', loyalty: 'neutral' }, { kindness: 'neutral', loyalty: 'chaotic' }, { kindness: 'evil', loyalty: 'loyal' }, { kindness: 'evil', loyalty: 'neutral' }, { kindness: 'evil', loyalty: 'chaotic' }];
export const defaultCharacter: Character = {
id: -1,
@ -715,14 +716,7 @@ class PeoplePicker implements BuilderTab
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: {
click: (e: Event) => {
this._builder.character.visibility = this._builder.character.visibility === "private" ? "public" : "private";
this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked");
}
}}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
this._visibilityInput = toggle({ defaultValue: this._builder.character.visibility === "private", change: (value) => this._builder.character.visibility = value ? "private" : "public" });
this._options = config.peoples.map(
(people, i) => dom("div", { class: "flex flex-col flex-nowrap gap-2 p-2 border border-light-35 dark:border-dark-35 cursor-pointer hover:border-light-70 dark:hover:border-dark-70 w-[320px]", listeners: { click: () => {

View File

@ -50,7 +50,7 @@ export function select<T extends NonNullable<any>>(options: Array<{ text: string
return dom('div', { listeners: { click: () => {
textValue.textContent = e.text;
settings?.change && settings?.change(e.value);
close && close();
context && context.close && context.close();
}, mouseenter: (e) => focus(i) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text(e.text) ]);
});
const select = dom('div', { listeners: { click: () => {
@ -96,6 +96,78 @@ export function select<T extends NonNullable<any>>(options: Array<{ text: string
})
return select;
}
export function multiselect<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
let context: { close: Function };
let focused: number | undefined;
let selection: T[] = settings?.defaultValue ?? [];
options = options.filter(e => !!e);
const focus = (i?: number) => {
focused !== undefined && optionElements[focused]?.toggleAttribute('data-focused', false);
i !== undefined && optionElements[i]?.toggleAttribute('data-focused', true) && optionElements[i]?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
focused = i;
}
let disabled = settings?.disabled ?? false;
const textValue = text(selection.length > 0 ? ((options.find(f => f?.value === selection[0])?.text ?? '') + (selection.length > 1 ? ` +${selection.length - 1}` : '')) : '');
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
const element = dom('div', { listeners: { click: () => {
selection = selection.includes(e.value) ? selection.filter(f => f !== e.value) : [...selection, e.value];
textValue.textContent = selection.length > 0 ? ((options.find(f => f?.value === selection[0])?.text ?? '') + (selection.length > 1 ? ` +${selection.length - 1}` : '')) : '';
element.toggleAttribute('data-selected', selection.includes(e.value));
settings?.change && settings?.change(selection);
context && context.close && context.close();
}, mouseenter: (e) => focus(i) }, class: ['group flex flex-row justify-between items-center data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option], attributes: { 'data-selected': selection.includes(e.value) } }, [ text(e.text), icon('radix-icons:check', { class: 'hidden group-data-[selected]:block', noobserver: true }) ]);
return element;
});
const select = dom('div', { listeners: { click: () => {
if(disabled)
return;
const handleKeys = (e: KeyboardEvent) => {
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(clamp((focused ?? -1) + 1, 0, options.length - 1));
return;
case 'arrowup':
focus(clamp((focused ?? 1) - 1, 0, options.length - 1));
return;
case 'pageup':
focus(0);
return;
case 'pagedown':
focus(optionElements.length - 1);
return;
case 'enter':
focused && optionElements[focused]?.click();
return;
case 'escape':
context?.close();
return;
default: return;
}
}
window.addEventListener('keydown', handleKeys);
const box = select.getBoundingClientRect();
context = 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` }, blur: () => window.removeEventListener('keydown', handleKeys) });
} }, 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: Option<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' }): HTMLElement
{
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
@ -294,7 +366,7 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
focus: () => settings?.focus && settings.focus(),
blur: () => settings?.blur && settings.blur(),
}});
if(settings?.defaultValue) field.value = storedValue.toString(10);
if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10);
return field;
}
@ -309,8 +381,23 @@ export function foldable(content: NodeChildren, title: NodeChildren, settings?:
return fold;
}
type TableRow = Record<string, (() => HTMLElement) | HTMLElement | string>;
export function table(content: TableRow[], headers: TableRow, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class } })
export function table(content: TableRow[], headers: TableRow, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class, cell?: Class } })
{
const render = (item: (() => HTMLElement) | HTMLElement | string) => typeof item === 'string' ? text(item) : typeof item === 'function' ? item() : item;
return dom('table', { class: ['', properties?.class?.table] }, [ dom('thead', { class: ['', properties?.class?.header] }, [ dom('tr', { class: '' }, Object.values(headers).map(e => dom('th', {}, [ render(e) ]))) ]), dom('tbody', { class: ['', properties?.class?.body] }, content.map(e => dom('tr', { class: ['', properties?.class?.row] }, Object.keys(headers).map(f => e.hasOwnProperty(f) ? dom('td', { class: '' }, [ render(e[f]!) ]) : undefined)))) ]);
return dom('table', { class: ['', properties?.class?.table] }, [ dom('thead', { class: ['', properties?.class?.header] }, [ dom('tr', { class: '' }, Object.values(headers).map(e => dom('th', {}, [ render(e) ]))) ]), dom('tbody', { class: ['', properties?.class?.body] }, content.map(e => dom('tr', { class: ['', properties?.class?.row] }, Object.keys(headers).map(f => e.hasOwnProperty(f) ? dom('td', { class: ['', properties?.class?.cell] }, [ render(e[f]!) ]) : undefined)))) ]);
}
export function toggle(settings?: { defaultValue?: boolean, change?: (value: boolean) => void, disabled?: boolean, class?: { container?: Class } })
{
let state = settings?.defaultValue ?? false;
const element = 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]`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked" }, listeners: {
click: (e: Event) => {
state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked");
settings?.change && settings.change(state);
}
}
}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
return element;
}

View File

@ -1,12 +1,12 @@
import type { Ability, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, Resistance } from "~/types/character";
import type { Ability, AspectConfig, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, Resistance, SpellConfig } from "~/types/character";
import { div, dom, icon, text, type NodeChildren } from "#shared/dom.util";
import { MarkdownEditor } from "#shared/editor.util";
import { fakeA } from "#shared/proses";
import { button, combobox, foldable, input, numberpicker, select, table, type Option } from "#shared/components.util";
import { button, combobox, foldable, input, multiselect, numberpicker, select, table, toggle, type Option } from "#shared/components.util";
import { fullblocker, tooltip } from "#shared/floating.util";
import { elementTexts, MAIN_STATS, mainStatShortTexts, mainStatTexts, spellTypeTexts } from "#shared/character.util";
import { ALIGNMENTS, alignmentToString, elementTexts, MAIN_STATS, mainStatShortTexts, mainStatTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts } from "#shared/character.util";
import characterConfig from "#shared/character-config.json";
import { clamp, getID, ID_SIZE } from "#shared/general.util";
import { getID, ID_SIZE } from "#shared/general.util";
import renderMarkdown, { renderText } from "#shared/markdown.util";
import { Tree } from "#shared/tree";
import markdownUtil from "#shared/markdown.util";
@ -42,8 +42,8 @@ export class HomebrewBuilder
new TrainingEditor(this, this._config),
new AbilityEditor(this, this._config),
new AspectEditor(this, this._config),
/* new SpellEditor(this),
new ListEditor(this), */
new SpellEditor(this, this._config),
/* new ListEditor(this), */
];
this._content = div('flex-1 outline-none max-w-full w-full overflow-y-auto');
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
@ -174,52 +174,114 @@ class AbilityEditor extends BuilderTab
{
super(builder, config);
Object.entries(config.abilities).map(e => div('flex flex-col gap-4 border border-light-25 dark:border-dark-25', [ ]))
this._content = [ table(Object.entries(config.abilities).map(e => ({
max1: div('', [ text(mainStatTexts[e[1].max[0]]) ]),
max2: div('', [ text(mainStatTexts[e[1].max[1]]) ]),
name: div('', [ text(e[1].name) ]),
description: div('', [ text(e[1].description) ]),
id: div('', [ text(e[0]) ]),
})), { id: 'ID', name: 'Nom', description: 'Description', max1: 'Stat 1', max2: 'Stat 2' }) ];
this._content = [ div('flex px-24 py-4', [table(Object.entries(config.abilities).map(e => ({
max1: select(MAIN_STATS.map(e => ({ text: mainStatTexts[e], value: e })), { change: (value) => e[1].max[0] = value, defaultValue: e[1].max[0], class: { container: 'w-full !m-0' } }),
max2: select(MAIN_STATS.map(e => ({ text: mainStatTexts[e], value: e })), { change: (value) => e[1].max[1] = value, defaultValue: e[1].max[1], class: { container: 'w-full !m-0' } }),
name: input('text', { input: (value) => e[1].name = value, placeholder: 'Nom', defaultValue: e[1].name, class: 'w-full !m-0' }),
description: input('text', { input: (value) => e[1].description = value, placeholder: 'Description', defaultValue: e[1].description, class: 'w-full !m-0' }),
id: div('w-full !m-0', [ text(e[0]) ]),
})), { id: 'ID', name: 'Nom', description: 'Description', max1: 'Stat 1', max2: 'Stat 2' }, { class: { table: 'flex-1' } })] ) ];
}
}
class AspectEditor extends BuilderTab
{
private _filter: boolean = true;
private _options: HTMLDivElement[];
constructor(builder: HomebrewBuilder, config: CharacterConfig)
{
super(builder, config);
/* this._options = config.aspects.map((e, i) => dom('div', { attributes: { "data-aspect": i.toString() }, listeners: { click: () => {
this._builder.character.aspect = i;
this._options.forEach(_e => _e.setAttribute('data-state', 'inactive'));
this._options[i]?.setAttribute('data-state', 'active');
}}, class: 'group flex flex-col w-[360px] border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 cursor-pointer' }, [
div('bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2 group-data-[state=active]:bg-accent-blue group-data-[state=active]:bg-opacity-10', [
div('flex flex-row gap-8 ps-4 items-center', [
div("flex flex-1 flex-col gap-2 justify-center", [ div('text-lg font-bold', [ text(e.name) ]), dom('span', { class: 'border-b w-full border-light-50 dark:border-dark-50 group-data-[state=active]:border-b-[4px] group-data-[state=active]:border-accent-blue' }) ]),
div('rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10')
])
]),
div('flex justify-stretch items-stretch py-2 px-4 gap-4', [
div('flex flex-col flex-1 items-stretch gap-4', [
div('flex flex-1 justify-between', [ text('Difficulté'), div('text-sm font-bold', [ text(e.difficulty.toString()) ]) ]),
div('flex flex-1 justify-between', [ text('Bonus'), div('text-sm font-bold', [ text(e.stat === 'special' ? 'Special' : mainStatTexts[e.stat]) ]) ])
]),
div('w-px h-full bg-light-50 dark:bg-dark-50'),
div('flex flex-col items-center justify-between py-2', [
div('text-sm italic', [ text(alignmentToString(e.alignment)) ]),
div(['text-sm font-bold', { "text-light-purple dark:text-dark-purple italic": e.magic, "text-light-orange dark:text-dark-orange": !e.magic }], [ text(e.magic ? 'Magie autorisée' : 'Magie interdite') ]),
]),
])
])); */
const render = (aspect: AspectConfig) => {
return {
name: input('text', { input: (value) => aspect.name = value, defaultValue: aspect.name, class: '!m-0 w-full' }),
description: input('text', { input: (value) => aspect.description = value, defaultValue: aspect.description, class: '!m-0 w-full' }),
stat: select(MAIN_STATS.map(f => ({ text: mainStatTexts[f], value: f })), { change: (value) => aspect.stat = value, defaultValue: aspect.stat, class: { container: '!m-0 w-full' } }),
alignment: select(ALIGNMENTS.map(f => ({ text: alignmentToString(f), value: f })), { change: (value) => aspect.alignment = value, defaultValue: aspect.alignment, class: { container: '!m-0 w-full' } }),
magic: toggle({ defaultValue: aspect.magic, change: (value) => aspect.magic = value, class: { container: '' } }),
difficulty: numberpicker({ min: 6, max: 13, input: (value) => aspect.difficulty = value, defaultValue: aspect.difficulty, class: '!m-0 w-full' }),
physic: div('flex flex-row justify-center gap-2', [ numberpicker({ defaultValue: aspect.physic.min, input: (value) => aspect.physic.min = value }), numberpicker({ defaultValue: aspect.physic.max, input: (value) => aspect.physic.max = value }) ]),
mental: div('flex flex-row justify-center gap-2', [ numberpicker({ defaultValue: aspect.mental.min, input: (value) => aspect.mental.min = value }), numberpicker({ defaultValue: aspect.mental.max, input: (value) => aspect.mental.max = value }) ]),
personality: div('flex flex-row justify-center gap-2', [ numberpicker({ defaultValue: aspect.personality.min, input: (value) => aspect.personality.min = value }), numberpicker({ defaultValue: aspect.personality.max, input: (value) => aspect.personality.max = value }) ]),
action: div('flex flex-row justify-center gap-2', [ button(icon('radix-icons:file-text'), () => {}, 'p-1'), button(icon('radix-icons:trash'), () => remove(aspect), 'p-1') ])
};
}
const add = () => {
config.aspects.push({
name: '',
description: '',
stat: 'strength',
alignment: { kindness: 'good', loyalty: 'loyal' },
magic: false,
difficulty: 6,
physic: { min: 0, max: 30 },
mental: { min: 0, max: 20 },
personality: { min: 0, max: 20 },
options: []
});
this._content = [ div('flex flex-row flex-wrap justify-center items-center flex-1 gap-8 mx-8 my-4 px-8', /* this._options */)];
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
};
const remove = (aspect: AspectConfig) => {
config.aspects = config.aspects.filter(e => e !== aspect);
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
}
const redraw = () => table(config.aspects.map(render), { name: 'Nom', description: 'Description', stat: 'Buff de stat', alignment: 'Alignement', magic: 'Magie', difficulty: 'Difficulté', physic: 'Physique', mental: 'Mental', personality: 'Caractère', action: 'Actions' }, { class: { table: 'flex-1' } });
let content = redraw();
this._content = [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ];
}
}
class SpellEditor extends BuilderTab
{
constructor(builder: HomebrewBuilder, config: CharacterConfig)
{
super(builder, config);
const render = (spell: SpellConfig) => {
return {
id: spell.id,
name: input('text', { input: (value) => spell.name = value, defaultValue: spell.name, class: '!m-0 w-full' }),
rank: select([{ text: 'Rang 1', value: 1 }, { text: 'Rang 2', value: 2 }, { text: 'Rang 3', value: 3 }, { text: 'Spécial', value: 4 }], { change: (value: 1 | 2 | 3 | 4) => spell.rank = value, defaultValue: spell.rank, class: { container: '!m-0 w-full' } }),
type: select(SPELL_TYPES.map(f => ({ text: spellTypeTexts[f], value: f })), { change: (value) => spell.type = value, defaultValue: spell.type, class: { container: '!m-0 w-full' } }),
cost: numberpicker({ defaultValue: spell.cost, input: (value) => spell.cost = value, class: '!m-0 w-full' }),
speed: select<'action' | 'reaction' | number>([{ text: 'Action', value: 'action' }, { text: 'Reaction', value: 'reaction' }, { text: '1 minute', value: 1 }, { text: '10 minutes', value: 10 }], { change: (value) => spell.speed = value, defaultValue: spell.speed, class: { container: '!m-0 w-full' } }),
elements: multiselect(SPELL_ELEMENTS.map(f => ({ text: elementTexts[f].text, value: f })), { change: (value) => spell.elements = value, defaultValue: spell.elements, class: { container: '!m-0 w-full' } }),
effect: input('text', { input: (value) => spell.effect = value, defaultValue: spell.effect, class: '!m-0 w-full' }),
tags: multiselect([{ text: 'Dégâts', value: 'damage' }, { text: 'Buff', value: 'buff' }, { text: 'Debuff', value: 'debuff' }, { text: 'Support', value: 'support' }, { text: 'Tank', value: 'tank' }, { text: 'Mouvement', value: 'movement' }, { text: 'Utilitaire', value: 'utilitary' }], { change: (value) => spell.tags = value, defaultValue: spell.tags, class: { container: '!m-0 w-full' } }),
concentration: toggle({ change: (value) => spell.concentration = value, defaultValue: spell.concentration, class: { container: '!m-0' } }),
action: div('flex flex-row justify-center gap-2', [ button(icon('radix-icons:trash'), () => remove(spell), 'p-1') ])
};
}
const add = () => {
config.spells.push({
id: getID(ID_SIZE),
name: '',
rank: 1,
type: 'precision',
cost: 1,
speed: 'action',
elements: [],
effect: '',
concentration: false,
tags: [],
});
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
};
const remove = (spell: SpellConfig) => {
config.spells = config.spells.filter(e => e !== spell);
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
}
const redraw = () => table(config.spells.map(render), { id: 'ID', name: 'Nom', rank: 'Rang', type: 'Type', cost: 'Coût', speed: 'Incantation', elements: 'Elements', effect: 'Effet', tags: 'Tag', concentration: 'Concentration', action: 'Actions' }, { class: { table: 'flex-1' } });
let content = redraw();
this._content = [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ];
}
}

View File

@ -63,6 +63,7 @@ export type SpellConfig = {
speed: "action" | "reaction" | number;
elements: Array<SpellElement>;
effect: string;
concentration: boolean;
tags?: string[];
};
export type AbilityConfig = {