Convert list texts to a separate i18n text, allowing translation and fixing action/passive/... removal. Character sheet now use the character compiler.

This commit is contained in:
Clément Pons 2025-08-25 17:35:15 +02:00
parent 247b14b2c8
commit 69ee62c08e
27 changed files with 2432 additions and 1003 deletions

View File

@ -39,6 +39,7 @@
"remark-rehype": "^11.1.2",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-vue": "^6.0.0",
"strip-markdown": "^6.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vue": "^3.5.17",
@ -1953,6 +1954,8 @@
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
"strip-markdown": ["strip-markdown@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw=="],
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
"structured-clone-es": ["structured-clone-es@1.0.0", "", {}, "sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ=="],

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import render, { type MDProperties } from '#shared/markdown.util'
const { content, filter, properties } = defineProps<{
content?: string,
filter?: string,
properties?: MDProperties
}>();
const container = useTemplateRef('container');
content && onMounted(() => {
queueMicrotask(() => {
container.value && content && container.value.replaceChildren(render(content, filter, properties));
})
})
</script>
<template>
<div ref="container"></div>
</template>

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import { hasPermissions } from '~/shared/auth.util';
const { path } = defineProps<{
path: string
filter?: string,

View File

@ -7,11 +7,14 @@ import RemarkOfm from 'remark-ofm';
import RemarkGfm from 'remark-gfm';
import RemarkBreaks from 'remark-breaks';
import RemarkFrontmatter from 'remark-frontmatter';
import StripMarkdown from 'strip-markdown';
import RemarkStringify from 'remark-stringify';
interface Parser
{
parse: (md: string) => Promise<Root>;
parseSync: (md: string) => Root
parseSync: (md: string) => Root;
text: (md: string) => string;
}
export default function useMarkdown(): Parser
{
@ -39,5 +42,17 @@ export default function useMarkdown(): Parser
return processed;
}
return { parse, parseSync };
const text = (markdown: string) => {
if (!processor)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter ]);
processor.use(StripMarkdown, { remove: [ 'comment', 'tag', 'callout' ] });
processor.use(RemarkStringify);
}
const processed = processor.processSync(markdown);
return String(processed);
}
return { parse, parseSync, text };
}

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -68,7 +68,7 @@ import { Content, iconByType } from '#shared/content.util';
import { dom, icon, text } from '#shared/dom.util';
import { unifySlug } from '#shared/general.util';
import { popper } from '#shared/floating.util';
import { link } from '#shared/proses';
import { link } from '#shared/components.util';
const options = ref<DropdownOption[]>([{
type: 'item',

View File

@ -120,7 +120,6 @@ export default defineNuxtConfig({
pageTransition: false,
layoutTransition: false
},
ssr: false,
components: [
{
path: '~/components',
@ -144,6 +143,7 @@ export default defineNuxtConfig({
},
runtimeConfig: {
session: {
maxAge: 60*60*24*31,
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013',
},
database: 'db.sqlite',

View File

@ -44,6 +44,7 @@
"remark-rehype": "^11.1.2",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-vue": "^6.0.0",
"strip-markdown": "^6.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vue": "^3.5.17",

View File

@ -5,6 +5,8 @@ import PreviewA from '~/components/prose/PreviewA.vue';
import { clamp } from '#shared/general.util';
import type { SpellConfig } from '~/types/character';
import type { CharacterConfig } from '~/types/character';
import { CharacterCompiler, defaultCharacter, elementTexts, spellTypeTexts } from '~/shared/character.util';
import { getText } from '~/shared/i18n';
const config = characterConfig as CharacterConfig;
@ -12,8 +14,10 @@ const id = useRouter().currentRoute.value.params.id;
const { user } = useUserSession();
const { add } = useToast();
const { data: character, status, error } = await useFetch(`/api/character/${id}/compiled`);
const { data, status, error } = await useFetch(`/api/character/${id}`);
const compiler = new CharacterCompiler(data.value ?? defaultCharacter);
console.log(compiler);
const character = ref(compiler.compiled);
/*
text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red
text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue
@ -48,12 +52,12 @@ text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-pur
</div>
<div class="flex flex-col">
<span class="font-bold">Niveau {{ character.level }}</span>
<span>{{ character.race === -1 ? "Race inconnue" : characterConfig.peoples[character.race].name }}</span>
<span>{{ character.race === -1 ? "Race inconnue" : config.peoples[character.race]!.name }}</span>
</div>
</div>
<div class="flex flex-col lg:border-l border-light-30 dark:border-dark-30 py-4 ps-4">
<span class="flex flex-row items-center gap-2">PV: {{ character.health - character.values.health }}/{{ character.health }}</span>
<span class="flex flex-row items-center gap-2">Mana: {{ character.mana - character.values.mana }}/{{ character.mana }}</span>
<span class="flex flex-row items-center gap-2">PV: {{ character.health - character.variables.health }}/{{ character.health }}</span>
<span class="flex flex-row items-center gap-2">Mana: {{ character.mana - character.variables.mana }}/{{ character.mana }}</span>
</div>
</div>
<div class="self-center">
@ -124,38 +128,38 @@ text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-pur
<TabsList class="flex flex-row relative px-4 gap-4">
<TabsIndicator class="absolute left-0 h-[3px] bottom-0 w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] transition-[width,transform] duration-300 bg-accent-blue"></TabsIndicator>
<TabsTrigger value="features" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Aptitudes</TabsTrigger>
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Sorts</TabsTrigger>
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue" v-if="character.spellslots > 0">Sorts</TabsTrigger>
<TabsTrigger value="notes" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Notes</TabsTrigger>
</TabsList>
<TabsContent value="features">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col">
<span class="text-lg font-semibold">Actions</span>
<span class="text-sm text-light-70 dark:text-dark-70">Attaquer - Saisir - Faire chuter - Déplacer - Courir - Pas de coté - Lancer un sort - S'interposer - Se transformer - Utiliser un objet - Anticiper une action - Improviser</span>
<MarkdownRenderer :content="character.features.action.join('\n')" />
<MarkdownRenderer :content="character.lists.action?.map(e => getText(e))?.join('\n')" />
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Réactions</span>
<span class="text-sm text-light-70 dark:text-dark-70">Parade - Esquive - Saisir une opportunité - Prendre en tenaille - Intercepter - Désarmer</span>
<MarkdownRenderer :content="character.features.reaction.join('\n')" />
<MarkdownRenderer :content="character.lists.reaction?.map(e => getText(e))?.join('\n')" />
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Actions libre</span>
<span class="text-sm text-light-70 dark:text-dark-70">Analyser une situation - Communiquer</span>
<MarkdownRenderer :content="character.features.freeaction.join('\n')" />
<MarkdownRenderer :content="character.lists.freeaction?.map(e => getText(e))?.join('\n')" />
</div>
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Aptitudes</span>
<MarkdownRenderer :content="character.features.passive.map(e => `> ${e}`).join('\n\n')" />
<MarkdownRenderer :content="character.lists.passive?.map(e => getText(e))?.map(e => `> ${e}`).join('\n\n')" />
</div>
</div>
</TabsContent>
<TabsContent v-if="character.spells.length > 0" value="spells">
<TabsContent v-if="character.spellslots > 0" value="spells">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<div class="flex flex-col">
<div class="pb-4 px-2 mt-4 border-b last:border-none border-light-30 dark:border-dark-30 flex flex-col" v-for="spell of character.spells.map(e => characterConfig.spells.find((f: SpellConfig) => f.id === e)).filter(e => !!e)">
<div class="flex flex-col" v-if="character.lists.spells && character.lists.spells.length > 0">
<div class="pb-4 px-2 mt-4 border-b last:border-none border-light-30 dark:border-dark-30 flex flex-col" v-for="spell of character.lists.spells.map(e => config.spells.find((f: SpellConfig) => f.id === e)).filter(e => !!e)">
<div class="flex flex-row justify-between">
<span class="text-lg font-bold">{{ spell.name }}</span>
<div class="flex flex-row items-center gap-6">

View File

@ -76,45 +76,6 @@ async function duplicateCharacter(id: number)
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<!--
<DropdownMenuRoot>
<DropdownMenuTrigger class="self-start">
<Button icon><Icon icon="radix-icons:dots-vertical" /></Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" side="bottom" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
<DropdownMenuItem @select="useRouter().push({ name: 'character-id-edit', params: { id: character.id } })" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-baseline py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon icon="radix-icons:pencil-1" class="absolute left-1.5" />
<span>Editer</span>
</DropdownMenuItem>
<DropdownMenuItem @select="duplicateCharacter(character.id)" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
<Icon icon="radix-icons:clipboard-copy" class="absolute left-1.5" />
<span>Dupliquer</span>
</DropdownMenuItem>
<AlertDialogTrigger>
<DropdownMenuItem class="cursor-pointer text-base text-light-red dark:text-dark-red leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-red dark:data-[highlighted]:bg-dark-red data-[highlighted]:bg-opacity-30 dark:data-[highlighted]:bg-opacity-30">
<Icon icon="radix-icons:trash" class="absolute left-1.5" />
<span>Supprimer</span>
</DropdownMenuItem>
</AlertDialogTrigger>
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot> -->
</div>
</div>
<div v-else>

View File

@ -15,7 +15,7 @@ const { data: characters, error, status } = await useFetch(`/api/character`, { p
<Avatar size="large" icon="radix-icons:person" src="" />
<div class="flex flex-1 flex-shrink flex-col truncate">
<NuxtLink class="text-xl font-bold hover:text-accent-blue truncate" :to="{ name: 'character-id', params: { id: character.id } }" :title="character.name">{{ character.name }}</NuxtLink>
<span class="text-sm truncate">Niveau {{ character.progress.level }}</span>
<span class="text-sm truncate">Niveau {{ character.level }}</span>
</div>
</div>
</div>

View File

@ -36,9 +36,9 @@
<script setup lang="ts">
import { Content, Editor } from '#shared/content.util';
import { button, loading } from '#shared/proses';
import { dom, icon, text } from '~/shared/dom.util';
import { modal, popper } from '~/shared/floating.util';
import { button, loading } from '#shared/components.util';
import { dom, icon, text } from '#shared/dom.util';
import { modal, popper } from '#shared/floating.util';
definePageMeta({
rights: ['admin', 'editor'],

View File

@ -106,5 +106,5 @@ function _useSession(event: H3Event) {
sessionConfig = runtimeConfig.session;
}
return useSession<UserSession>(event, sessionConfig)
return useSession<UserSession>(event, sessionConfig);
}

View File

@ -1,12 +1,11 @@
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
import { clamp, lerp } from "#shared/general.util";
import { dom, icon, svg, text } from "./dom.util";
import render from "./markdown.util";
import { dom, icon, svg, text } from "#shared/dom.util";
import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util";
import { Content } from "./content.util";
import { History } from "./history.util";
import { fakeA, link } from "./proses";
import { SnapFinder, SpatialGrid } from "./physics.util";
import { History } from "#shared/history.util";
import { fakeA } from "#shared/proses";
import { SpatialGrid } from "#shared/physics.util";
import type { CanvasPreferences } from "~/types/general";
export type Direction = 'bottom' | 'top' | 'left' | 'right';
@ -424,7 +423,6 @@ export class Canvas
delay: 120, arrow: true, placement: 'right', offset: 8, content: [text('Zoom arrière')], class: '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'
}),
]),
//link({}, { name: 'explore-edit' }),
]), this.transform,
]);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,12 @@
import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureID, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, Feature, FeatureID, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import { z } from "zod/v4";
import characterConfig from './character-config.json';
import { button, fakeA, input, loading, numberpicker, select } from "./proses";
import { div, dom, icon, mergeClasses, text, type Class } from "./dom.util";
import { contextmenu, followermenu, popper } from "./floating.util";
import { clamp } from "./general.util";
import markdownUtil from "./markdown.util";
import characterConfig from '#shared/character-config.json';
import { fakeA } from "#shared/proses";
import { button, input, loading, numberpicker, select } 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";
import markdownUtil from "#shared/markdown.util";
const config = characterConfig as CharacterConfig;
@ -46,10 +47,17 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
race: character.people!,
modifier: MAIN_STATS.reduce((p, v) => { p[v] = 0; return p; }, {} as Record<MainStat, number>),
level: character.level,
values: {
variables: {
health: character.health,
mana: character.mana
mana: character.mana,
equipment: [],
exhaustion: 0,
sickness: [],
},
action: 0,
reaction: 0,
exhaust: 0,
itempower: 0,
features: {
action: [],
reaction: [],
@ -194,158 +202,34 @@ const stepTexts: Record<number, string> = {
type Property = { value: number | string | false, id: string, operation: "set" | "add" };
type PropertySum = { list: Array<Property>, value: number, _dirty: boolean };
export class CharacterBuilder
export class CharacterCompiler
{
private _container: HTMLDivElement;
private _content?: HTMLDivElement;
private _stepsHeader: HTMLDivElement[] = [];
private _stepsContent: BuilderTab[] = [];
private _helperText!: Text;
private id?: string;
protected _character!: Character;
protected _result!: CompiledCharacter;
protected _buffer: Record<string, PropertySum> = {};
private _character!: Character;
private _result!: CompiledCharacter;
private _buffer: Record<string, PropertySum> = {};
constructor(container: HTMLDivElement, id?: string)
constructor(character: Character)
{
this.id = id;
this._container = container;
this.character = character;
}
if(id)
set character(value: Character)
{
this._character = value;
this._result = defaultCompiledCharacter(value);
this._buffer = {};
if(value.people !== undefined)
{
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
container.replaceChildren(load);
useRequestFetch()(`/api/character/${id}`).then(character => {
if(character)
{
this._character = character;
Object.entries(value.leveling).forEach(e => this.add(config.peoples[value.people!]!.options[parseInt(e[0]) as Level][e[1]]!));
document.title = `d[any] - Edition de ${character.name ?? 'nouveau personnage'}`;
if(character.people !== undefined)
{
const people = config.peoples[character.people]!;
this._result = defaultCompiledCharacter(this._character);
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 => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))
});
}
load.remove();
this.render();
this.display(0);
}
MAIN_STATS.forEach(stat => {
Object.entries(value.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))
});
}
else
{
this._character = Object.assign({}, defaultCharacter);
this._result = defaultCompiledCharacter(this._character);
document.title = `d[any] - Edition de nouveau personnage`;
this.render();
this.display(0);
Object.entries(value.abilities).forEach(e => this._result.abilities[e[0] as Ability] = e[1]);
}
}
private render()
{
this._stepsHeader = [
dom("div", { class: "group flex items-center", }, [
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(0) } }, [text("Peuples")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(1) } }, [text("Niveaux")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(2) } }, [text("Entrainement")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(3) } }, [text("Compétences")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(4) } }, [text("Aspect")])
]),
];
this._stepsContent = [
new PeoplePicker(this),
new LevelPicker(this),
new TrainingPicker(this),
new AbilityPicker(this),
new AspectPicker(this),
];
this._helperText = text("Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.")
this._content = dom('div', { class: 'flex-1 outline-none max-w-full w-full overflow-y-auto', attributes: { id: 'characterEditorContainer' } });
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [
div(), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ popper(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), {
arrow: true,
offset: 8,
content: [ this._helperText ],
placement: "bottom-end",
class: "max-w-96 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"
}) ]),
]),
this._content,
]));
}
display(step: number)
{
if(step < 0 || step >= this._stepsHeader.length)
return;
if(this._stepsContent.slice(0, step).some(e => !e.validate()))
return;
this._stepsHeader.forEach(e => e.setAttribute('data-state', 'inactive'));
this._stepsHeader[step]!.setAttribute('data-state', 'active');
this._stepsContent[step]!.update();
this._content?.replaceChildren(...this._stepsContent[step]!.dom);
this._helperText.textContent = stepTexts[step]!;
}
async save(leave: boolean = true)
{
if(this.id === 'new')
{
//@ts-ignore
this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, {
method: 'post',
body: this._character,
onResponseError: (e) => {
//add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
}
});
//add({ content: 'Personnage créé', type: 'success', duration: 25000, timer: true });
useRouter().replace({ name: 'character-id-edit', params: { id: this.id } })
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
}
else
{
//@ts-ignore
await useRequestFetch()(`/api/character/${this._character.id}`, {
method: 'post',
body: this._character,
onResponseError: (e) => {
//add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
}
});
//add({ content: 'Personnage enregistré', type: 'success', duration: 25000, timer: true });
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
}
}
get character(): Character
{
return this._character;
@ -367,7 +251,87 @@ export class CharacterBuilder
}, {} as Record<string, number>);
}
private compile(properties: string[])
protected add(feature?: string)
{
if(!feature)
return;
config.features[feature]?.effect.forEach(this.apply.bind(this));
}
protected remove(feature?: string)
{
if(!feature)
return;
config.features[feature]?.effect.forEach(this.undo.bind(this));
}
protected apply(feature?: FeatureItem)
{
if(!feature)
return;
switch(feature.category)
{
case "list":
if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
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, id: feature.id, value: feature.value });
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id];
if(choice)
choice.forEach(e => this.apply(feature.options[e]!));
return;
default:
return;
}
}
protected undo(feature?: FeatureItem)
{
if(!feature)
return;
switch(feature.category)
{
case "list":
if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
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.id === feature.id), 1);
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id];
if(choice)
choice.forEach(e => this.undo(feature.options[e]!));
return;
default:
return;
}
}
protected compile(properties: string[])
{
const queue = properties;
queue.forEach(property => {
@ -413,7 +377,7 @@ export class CharacterBuilder
}
const path = property.split("/");
const object = path.length === 1 ? this._result : path.slice(0, -1).reduce((p, v) => p[v], this._result as any);
const object = path.length === 1 ? this._result : path.slice(0, -1).reduce((p, v) => { p[v] ??= {}; return p[v]; }, this._result as any);
if(object.hasOwnProperty(path.slice(-1)[0]!))
object[path.slice(-1)[0]!] = sum;
@ -423,6 +387,141 @@ export class CharacterBuilder
}
});
}
}
export class CharacterBuilder extends CharacterCompiler
{
private _container: HTMLDivElement;
private _content?: HTMLDivElement;
private _stepsHeader: HTMLDivElement[] = [];
private _stepsContent: Array<BuilderTab | (() => BuilderTab)> = [];
private _helperText!: Text;
private id?: string;
constructor(container: HTMLDivElement, id?: string)
{
super(Object.assign({}, defaultCharacter));
this.id = id;
this._container = container;
if(id)
{
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
container.replaceChildren(load);
useRequestFetch()(`/api/character/${id}`).then(character => {
if(character)
{
this._character = character;
document.title = `d[any] - Edition de ${character.name ?? 'nouveau personnage'}`;
load.remove();
this.render();
this.display(0);
}
});
}
else
{
document.title = `d[any] - Edition de nouveau personnage`;
this.render();
this.display(0);
}
}
private render()
{
this._stepsHeader = [
dom("div", { class: "group flex items-center", }, [
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(0) } }, [text("Peuples")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(1) } }, [text("Niveaux")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(2) } }, [text("Entrainement")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(3) } }, [text("Compétences")]),
]),
dom("div", { class: "group flex items-center", }, [
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(4) } }, [text("Aspect")])
]),
];
this._stepsContent = [
new PeoplePicker(this),
() => new LevelPicker(this),
() => new TrainingPicker(this),
() => new AbilityPicker(this),
() => new AspectPicker(this),
];
this._helperText = text("Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.")
this._content = dom('div', { class: 'flex-1 outline-none max-w-full w-full overflow-y-auto', attributes: { id: 'characterEditorContainer' } });
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [
div(), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ popper(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), {
arrow: true,
offset: 8,
content: [ this._helperText ],
placement: "bottom-end",
class: "max-w-96 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"
}) ]),
]),
this._content,
]));
}
display(step: number)
{
if(step < 0 || step >= this._stepsHeader.length)
return;
if(step !== 0 && this._stepsContent.slice(0, step).some(e => !(e as BuilderTab).validate()))
return;
this._stepsContent.forEach((e, i, arr) => arr[i] = i <= step ? typeof e === 'function' ? e() : e : e);
this._stepsHeader.forEach(e => e.setAttribute('data-state', 'inactive'));
this._stepsHeader[step]!.setAttribute('data-state', 'active');
(this._stepsContent[step]! as BuilderTab).update();
this._content?.replaceChildren(...(this._stepsContent[step] as BuilderTab)!.dom);
this._helperText.textContent = stepTexts[step]!;
}
async save(leave: boolean = true)
{
if(this.id === 'new')
{
//@ts-ignore
this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, {
method: 'post',
body: this._character,
onResponseError: (e) => {
//add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
}
});
//add({ content: 'Personnage créé', type: 'success', duration: 25000, timer: true });
useRouter().replace({ name: 'character-id-edit', params: { id: this.id } })
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
}
else
{
//@ts-ignore
await useRequestFetch()(`/api/character/${this._character.id}`, {
method: 'post',
body: this._character,
onResponseError: (e) => {
//add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
}
});
//add({ content: 'Personnage enregistré', type: 'success', duration: 25000, timer: true });
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
}
}
updateLevel(level: Level)
{
this._character.level = level;
@ -513,20 +612,6 @@ export class CharacterBuilder
this.add(config.training[stat][level][choice]);
}
}
private add(feature?: string)
{
if(!feature)
return;
config.features[feature]?.effect.forEach(this.apply.bind(this));
}
private remove(feature?: string)
{
if(!feature)
return;
config.features[feature]?.effect.forEach(this.undo.bind(this));
}
private choose(id: string, choices: number[])
{
const current = this._character.choices[id];
@ -547,72 +632,6 @@ export class CharacterBuilder
this._character.choices[id] = choices;
}
}
private apply(feature?: FeatureItem)
{
if(!feature)
return;
switch(feature.category)
{
case "list":
if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
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, id: feature.id, value: feature.value });
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id];
if(choice)
choice.forEach(e => this.apply(feature.options[e]!));
return;
default:
return;
}
}
private undo(feature?: FeatureItem)
{
if(!feature)
return;
switch(feature.category)
{
case "list":
if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
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.id === feature.id), 1);
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id];
if(choice)
choice.forEach(e => this.undo(feature.options[e]!));
return;
default:
return;
}
}
}
type PickableFeatureSettings = { state?: boolean, onToggle?: (state: boolean) => void, onChoice?: (options: number[]) => void, disabled?: boolean, class?: { selected?: Class, container?: Class, disabled?: Class }, choices?: Record<string, number[]>, };

316
shared/components.util.ts Normal file
View File

@ -0,0 +1,316 @@
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node } from "./dom.util";
import { contextmenu, followermenu } from "./floating.util";
import { clamp } from "./general.util";
import { Tree } from "./tree";
export function link(properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>, children?: NodeChildren)
{
const router = useRouter();
const nav = link ? router.resolve(link) : undefined;
return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active ? mergeClasses(properties?.active) : undefined }, listeners: link ? {
click: function(e)
{
e.preventDefault();
router.push(link);
}
} : undefined }, children);
}
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElement
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
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 type Option<T> = { text: string, render?: () => HTMLElement, value: T | Option<T>[] } | undefined;
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredOption<T>> };
export function select<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;
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(options.find(e => Array.isArray(e) ? false : e?.value === settings?.defaultValue)?.text ?? '');
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
return dom('div', { listeners: { click: () => {
textValue.textContent = e.text;
settings?.change && settings?.change(e.value);
close && 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: () => {
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 };
let selected = true, tree: StoredOption<T>[] = [];
let focused: number | undefined;
let currentOptions: StoredOption<T>[] = [];
const focus = (value?: T | Option<T>[]) => {
focused !== undefined && currentOptions[focused]?.dom.toggleAttribute('data-focused', false);
if(value !== undefined)
{
const i = currentOptions.findIndex(e => e.item?.value === value);
if(i !== -1)
{
currentOptions[i]?.dom.toggleAttribute('data-focused', true);
currentOptions[i]?.dom.scrollIntoView({ behavior: 'instant', block: 'nearest' });
focused = i;
}
else
{
focused = undefined;
}
}
else
{
focused = undefined;
}
}
const show = () => {
if(disabled || (context && context.container.parentElement))
return;
const box = container.getBoundingClientRect();
focus();
context = followermenu(container, currentOptions.length > 0 ? currentOptions.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-y-auto overflow-x-hidden', settings?.class?.popup], style: { "min-width": settings?.fill === 'cover' && `${box.width}px`, "max-width": settings?.fill === 'contain' && `${box.width}px` }, blur: hide });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red');
tree = [];
context && context.container.parentElement && context.close();
};
const progress = (option: StoredOption<T>) => {
if(!context || !context.container.parentElement || option.container === undefined)
return;
context.container.replaceChildren(option.container);
tree.push(option);
currentOptions = option.children!;
focus();
};
const back = () => {
tree.pop();
const last = tree.slice(-1)[0];
currentOptions = last?.children ?? optionElements;
last ? context.container.replaceChildren(last.container ?? last.dom) : context.container.replaceChildren(...optionElements.filter(e => !!e).map(e => e.dom));
};
const render = (option: Option<T>): StoredOption<T> | undefined => {
if(option === undefined)
return;
if(Array.isArray(option.value))
{
const children = option.value.map(render).filter(e => !!e);
const stored = { item: option, dom: dom('div', { listeners: { click: () => progress(stored), mouseenter: () => focus(option.value) }, 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 flex justify-between items-center', settings?.class?.option] }, [ text(option.text), icon('radix-icons:caret-right', { width: 20, height: 20 }) ]), container: div('flex flex-1 flex-col', [div('flex flex-row justify-between items-center text-light-100 dark:text-dark-100 py-1 px-2 text-sm select-none sticky top-0 bg-light-20 dark:bg-dark-20 font-semibold', [button(icon('radix-icons:caret-left', { width: 16, height: 16 }), back, 'p-px'), text(option.text), div()]), div('flex flex-col flex-1', children.map(e => e?.dom))]), children };
return stored;
}
else
{
return { item: option, dom: dom('div', { listeners: { click: () => {
select.value = option.text;
settings?.change && settings?.change(option.value as T);
selected = true;
hide();
}, mouseenter: () => focus(option.value) }, 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] }, [ option?.render ? option?.render() : text(option.text) ]) };
}
}
const filter = (value: string, option?: StoredOption<T>): StoredOption<T>[] => {
if(option && option.children !== undefined)
{
return option.children.flatMap(e => filter(value, e));
}
else if(option && option.item)
{
return option.item.text.toLowerCase().normalize().includes(value) ? [ option ] : [];
}
else
{
return [];
}
}
let disabled = settings?.disabled ?? false;
const optionElements = currentOptions = options.map(render).filter(e => !!e);
const select = dom('input', { listeners: { focus: show, input: () => {
focus();
currentOptions = context && select.value ? optionElements.flatMap(e => filter(select.value.toLowerCase().trim().normalize(), e)) : optionElements.filter(e => !!e);
context && context.container.replaceChildren(...currentOptions.map(e => e.dom));
selected = false;
if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red')
}, keydown: (e) => {
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(currentOptions[clamp((focused ?? -1) + 1, 0, currentOptions.length - 1)]?.item?.value);
return;
case 'arrowup':
focus(currentOptions[clamp((focused ?? 1) - 1, 0, currentOptions.length - 1)]?.item?.value);
return;
case 'pageup':
focus(currentOptions[0]?.item?.value);
return;
case 'pagedown':
focus(currentOptions[currentOptions.length - 1]?.item?.value);
return;
case 'enter':
focused !== undefined ? currentOptions[focused]?.dom.click() : currentOptions[0]?.dom.click();
return;
case 'escape':
context?.close();
return;
default: return;
}
} }, 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' });
settings?.defaultValue && Tree.each(options, 'value', (item) => { if(item.value === settings?.defaultValue) select.value = item.text });
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, placeholder?: string }): HTMLInputElement
{
const input = dom("input", { attributes: { disabled: settings?.disabled, placeholder: settings?.placeholder }, 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;
}
// Open by default
export function foldable(content: NodeChildren, title: NodeChildren, settings?: { open?: boolean, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } })
{
const fold = div(['group flex flex-1 w-full flex-col', settings?.class?.container], [
div('flex', [ dom('div', { listeners: { click: () => fold.toggleAttribute('data-active') }, class: ['flex justify-center items-center', settings?.class?.icon] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center' }) ]), div(['flex-1', settings?.class?.title], title) ]),
div(['hidden group-data-[active]:flex', settings?.class?.content], content),
]);
fold.toggleAttribute('data-active', settings?.open ?? true);
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 } })
{
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)))) ]);
}

View File

@ -3,7 +3,8 @@ import { Canvas, CanvasEditor } from "#shared/canvas.util";
import render from "#shared/markdown.util";
import { confirm, contextmenu, popper } from "#shared/floating.util";
import { cancelPropagation, dom, icon, text, type Node } from "#shared/dom.util";
import prose, { h1, h2, loading } from "#shared/proses";
import { loading } from "#shared/components.util";
import prose, { h1, h2 } from "#shared/proses";
import { getID, ID_SIZE, parsePath } from '#shared/general.util';
import { TreeDOM, type Recursive } from '#shared/tree';
import { History } from '#shared/history.util';

View File

@ -1,14 +1,16 @@
import type { Ability, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, Resistance } from "~/types/character";
import { div, dom, icon, text, type NodeChildren } from "./dom.util";
import { MarkdownEditor } from "./editor.util";
import { button, combobox, fakeA, foldable, input, numberpicker, select, type Option } from "./proses";
import { fullblocker, tooltip } from "./floating.util";
import { MAIN_STATS, mainStatShortTexts, mainStatTexts } from "./character.util";
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 { fullblocker, tooltip } from "#shared/floating.util";
import { elementTexts, MAIN_STATS, mainStatShortTexts, mainStatTexts, spellTypeTexts } from "#shared/character.util";
import characterConfig from "#shared/character-config.json";
import { clamp, getID, ID_SIZE } from "./general.util";
import renderMarkdown from "./markdown.util";
import { Tree } from "./tree";
import markdownUtil from "./markdown.util";
import { clamp, 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";
import { getText } from "#shared/i18n";
const config = characterConfig as CharacterConfig;
export class HomebrewBuilder
@ -168,76 +170,19 @@ class TrainingEditor extends BuilderTab
}
class AbilityEditor extends BuilderTab
{
private _options: HTMLDivElement[];
private _tooltips: Text[] = [];
private _maxs: HTMLElement[] = [];
constructor(builder: HomebrewBuilder, config: CharacterConfig)
{
super(builder, config);
const numberInput = (value?: number, update?: (value: number) => number | undefined) => {
const input = dom("input", { 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`, listeners: {
input: (e: Event) => {
input.value = (update && update(parseInt(input.value))?.toString()) ?? input.value;
},
keydown: (e: KeyboardEvent) => {
let value = isNaN(parseInt(input.value)) ? '0' : input.value;
switch(e.key)
{
case "ArrowUp":
value = clamp(parseInt(value) + 1, 0, 99).toString();
break;
case "ArrowDown":
value = clamp(parseInt(value) - 1, 0, 99).toString();
break;
default:
break;
}
Object.entries(config.abilities).map(e => div('flex flex-col gap-4 border border-light-25 dark:border-dark-25', [ ]))
if(input.value !== value)
{
input.value = (update && update(parseInt(value))?.toString()) ?? value;
}
}
}});
input.value = value?.toString() ?? "0";
return input;
};
function pushAndReturn<T extends any>(arr: Array<T>, value: T): T
{
arr.push(value);
return value;
}
/* this._options = ABILITIES.map((e, i) => div('flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative', [
div('flex justify-between', [ numberpicker({ defaultValue: this._builder.character.abilities[e], input: (value) => {
const values = this._builder.values;
const max = (values[`abilities/${e}/max`] ?? 0) + (values[`modifier/${config.abilities[e].max[0]}`] ?? 0) + (values[`modifier/${config.abilities[e].max[1]}`] ?? 0);
this._builder.character.abilities[e] = clamp(value, 0, max);
Object.assign((this._options[i]?.lastElementChild as HTMLSpanElement | undefined)?.style ?? {}, { width: `${(max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100}%` });
this._tooltips[i]!.textContent = `${mainStatTexts[config.abilities[e].max[0]]} (${values[`modifier/${config.abilities[e].max[0]}`] ?? 0}) + ${mainStatTexts[config.abilities[e].max[1]]} (${values[`modifier/${config.abilities[e].max[1]}`] ?? 0}) + ${values[`abilities/${e}/max`] ?? 0}`;
this._maxs[i]!.textContent = `/ ${max ?? 0}`;
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
this._pointsInput.value = ((values.ability ?? 0) - abilities).toString();
return this._builder.character.abilities[e];
}}), popper(pushAndReturn(this._maxs, dom('span', { class: 'text-lg text-end cursor-pointer', text: '' })), {
arrow: true,
offset: 6,
placement: 'bottom-end',
class: 'max-w-96 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',
content: [ pushAndReturn(this._tooltips, text('')) ]
})]),
dom('span', { class: "text-xl text-center font-bold", text: config.abilities[e].name }),
dom('span', { class: "absolute -bottom-px -left-px h-[3px] bg-accent-blue" }),
])); */
this._content = [ div('flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48', /* this._options */)];
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' }) ];
}
}
class AspectEditor extends BuilderTab
@ -413,12 +358,12 @@ export class FeatureEditor
{
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 hover:z-10 h-[36px]' } }) ];
bottom = [ combobox(config.spells.map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }), div('flex flex-row gap-8', [ dom('span', { class: 'italic', text: `Rang ${e.rank === 4 ? 'spécial' : e.rank}` }), dom('span', { text: spellTypeTexts[e.type] }) ]) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderText(e.effect)) ]) ]), 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 hover:z-10 h-[36px] w-full hover:outline-px outline-light-50 dark:outline-dark-50 !border-none' }, fill: 'contain' }) ];
}
else
{
const editor = new MarkdownEditor();
editor.content = buffer.item ?? '';
editor.content = getText(buffer.item);
editor.onChange = (item) => (buffer as Extract<FeatureEffect, { category: "list" }>).item = item;
bottom = [ div('px-2 py-1 bg-light-25 dark:bg-dark-25 flex-1 flex items-center', [ editor.dom ]) ];
@ -426,7 +371,7 @@ export class FeatureEditor
}
else
{
bottom = [ select(Object.values(config.features).flatMap(e => e.effect).filter(e => e.category === 'list' && e.list === buffer.list && e.action === 'add').map(e => ({ text: buffer.list !== 'spells' ? (e as Extract<FeatureItem, { category: 'list' }>).item : config.spells.find(f => f.id === (e as Extract<FeatureItem, { category: 'list' }>).item)?.name ?? '', value: e.id })), { defaultValue: buffer.item, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full overflow-hidden truncate', option: 'max-h-[90px] text-sm' } }) ];
bottom = [ combobox(Object.values(config.features).flatMap(e => e.effect).filter(e => e.category === 'list' && e.list === buffer.list && e.action === 'add').map(e => ({ text: buffer.list !== 'spells' ? renderText(getText((e as Extract<FeatureItem, { category: 'list' }>).item)) : config.spells.find(f => f.id === (e as Extract<FeatureItem, { category: 'list' }>).item)?.name ?? '', value: (e as Extract<FeatureItem, { category: 'list' }>).item })), { defaultValue: buffer.item, change: (item) => buffer.item = item, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full overflow-hidden truncate', option: 'max-h-[90px] text-sm' }, fill: 'contain' }) ];
}
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';
@ -443,7 +388,7 @@ export class FeatureEditor
};
const render = (option: FeatureEffect & { text: string }, state: boolean): HTMLElement => {
const { top: _top, bottom: _bottom } = drawByCategory(option);
const combo = combobox([...featureChoices].filter(e => (e?.value as FeatureItem)?.category !== 'choice').map(e => { if(e) e.value = Array.isArray(e.value) ? e.value.filter(f => (f?.value as FeatureItem)?.category !== 'choice') : e.value; return e; }), { defaultValue: match(option), class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, change: (e) => {
const combo = combobox([...featureChoices].filter(e => (e?.value as FeatureItem)?.category !== 'choice').map(e => { if(e) e.value = Array.isArray(e.value) ? e.value.filter(f => (f?.value as FeatureItem)?.category !== 'choice') : e.value; return e; }), { defaultValue: match(option), class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, fill: 'cover', change: (e) => {
option = { id: option.id, ...e } as FeatureEffect & { text: string };
const element = render(option, true);
_content?.parentElement?.replaceChild(element, _content);
@ -467,7 +412,7 @@ export class FeatureEditor
const { top, bottom } = drawByCategory(_buffer);
return div('border border-light-30 dark:border-dark-30 col-span-2 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row flex-1', [
combobox(featureChoices, { defaultValue: match(_buffer), class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, change: (e) => {
combobox(featureChoices, { defaultValue: match(_buffer), class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, fill: 'cover', change: (e) => {
_buffer = { id: _buffer.id, ...e } as FeatureItem;
const element = redraw();
content?.parentElement?.replaceChild(element, content);
@ -672,6 +617,13 @@ function textFromEffect(effect: Partial<FeatureItem>): string
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 'bonus':
switch(splited[1])
{
case 'resistance':
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 'resistance':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: `Difficulté des jets de résistance de ${config.resistances[splited[1] as Resistance]!.name} `, positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : textFromValue(effect.value, { prefix: { truely: `Difficulté des jets de résistance de ${config.resistances[splited[1] as Resistance]!.name} fixé à ` }, suffix: { truely: '.' }, falsely: `Opération interdite (${config.resistances[splited[1] as Resistance]!.name} = interdit).` });
case 'abilities':
@ -691,7 +643,7 @@ function textFromEffect(effect: Partial<FeatureItem>): string
case 'reaction':
case 'freeaction':
case 'passive':
return effect.action === 'add' ? effect.item ?? '' : 'Suppression d\'effet.';
return effect.action === 'add' ? getText(effect.item) ?? '' : 'Suppression d\'effet.';
case 'spells':
return effect.action === 'add' ? `Maitrise du sort "${config.spells.find(e => e.id === effect.item)?.name ?? 'Sort inconnu'}".` : `Perte de maitrise du sort "${config.spells.find(e => e.id === effect.item)?.name ?? 'Sort inconnu'}".`;
case 'sickness':

View File

@ -1,6 +1,6 @@
import * as FloatingUI from "@floating-ui/dom";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom.util";
import { button } from "./proses";
import { button } from "./components.util";
export interface FloatingProperties
{
@ -180,7 +180,6 @@ export function followermenu(target: FloatingUI.ReferenceElement, content: NodeC
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.hide({ rootBoundary: rect, strategy: "escaped" }),
FloatingUI.hide({ rootBoundary: rect }),
FloatingUI.shift({ rootBoundary: rect }),
FloatingUI.flip({ rootBoundary: rect }),
@ -196,7 +195,7 @@ export function followermenu(target: FloatingUI.ReferenceElement, content: NodeC
Object.assign(container.style, {
left: `${x}px`,
top: `${y}px`,
visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible',
visibility: middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
});
const side = placement.split('-')[0] as FloatingUI.Side;

9
shared/i18n.ts Normal file
View File

@ -0,0 +1,9 @@
import type { CharacterConfig } from "~/types/character";
import characterConfig from '#shared/character-config.json';
const config = characterConfig as CharacterConfig;
export function getText(id?: string, lang?: string)
{
return id ? (config.texts.hasOwnProperty(id) ? config.texts[id][lang ?? "default"] : '') : undefined;
}

View File

@ -1,15 +1,19 @@
import type { Root, RootContent } from "hast";
import { dom, styling, text, type Class, type Node } from "./dom.util";
import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout, type Prose } from "./proses";
import { dom, styling, 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";
import { parseId } from "./general.util";
import { loading } from "#shared/proses";
import { parseId } from "#shared/general.util";
import { loading } from "#shared/components.util";
export function renderMarkdown(markdown: Root, proses: Record<string, Prose>): HTMLDivElement
{
return dom('div', {}, markdown.children.map(e => renderContent(e, proses)));
}
export function renderText(markdown: string): string
{
return useMarkdown().text(markdown);
}
function renderContent(node: RootContent, proses: Record<string, Prose>): Node
{

View File

@ -1,12 +1,11 @@
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses, text, div } from "#shared/dom.util";
import { dom, icon, type NodeChildren, type Node } from "#shared/dom.util";
import { parseURL } from 'ufo';
import render from "#shared/markdown.util";
import { contextmenu, popper } from "#shared/floating.util";
import { 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 { clamp, unifySlug } from "#shared/general.util";
import { Tree } from "./tree";
import { unifySlug } from "#shared/general.util";
export type CustomProse = (properties: any, children: NodeChildren) => Node;
export type Prose = { class: string } | { custom: CustomProse };
@ -211,289 +210,3 @@ export default function(tag: string, prose: Prose, children?: NodeChildren, prop
return prose.custom(properties, children ?? []);
}
}
export function link(properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>, children?: NodeChildren)
{
const router = useRouter();
const nav = link ? router.resolve(link) : undefined;
return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active ? mergeClasses(properties?.active) : undefined }, listeners: link ? {
click: function(e)
{
e.preventDefault();
router.push(link);
}
} : undefined }, children);
}
export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElement
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
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 type Option<T> = { text: string, value: T | Option<T>[] } | undefined;
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredOption<T> | undefined>, index: number };
export function select<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;
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(options.find(e => Array.isArray(e) ? false : e?.value === settings?.defaultValue)?.text ?? '');
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
return dom('div', { listeners: { click: () => {
textValue.textContent = e.text;
settings?.change && settings?.change(e.value);
close && 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: () => {
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 }): HTMLElement
{
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true, tree: StoredOption<T>[] = [];
let focused: number | undefined;
const focus = (i?: number) => {
focused !== undefined && (tree.slice(-1)[0]?.children ?? optionElements)[focused]?.dom.toggleAttribute('data-focused', false);
i !== undefined && (tree.slice(-1)[0]?.children ?? optionElements)[i]?.dom.toggleAttribute('data-focused', true) && (tree.slice(-1)[0]?.children ?? optionElements)[i]?.dom.scrollIntoView({ behavior: 'instant', block: 'nearest' });
focused = i;
}
const show = () => {
if(disabled || (context && context.container.parentElement))
return;
const box = container.getBoundingClientRect();
focus();
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-y-auto overflow-x-hidden', settings?.class?.popup], style: { "min-width": `${box.width}px` }, blur: hide });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red');
tree = [];
context && context.container.parentElement && context.close();
};
const progress = (option: StoredOption<T>) => {
if(!context || !context.container.parentElement || option.container === undefined)
return;
context.container.replaceChildren(option.container);
tree.push(option);
focus();
};
const back = () => {
tree.pop();
const last = tree.slice(-1)[0];
last ? context.container.replaceChildren(last.container ?? last.dom) : context.container.replaceChildren(...optionElements.filter(e => !!e).map(e => e.dom));
};
const render = (option: Option<T>, i: number): StoredOption<T> | undefined => {
if(option === undefined)
return;
if(Array.isArray(option.value))
{
const children = option.value.map(render);
const stored = { index: i, item: option, dom: dom('div', { listeners: { click: () => progress(stored), mouseenter: () => 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 flex justify-between items-center', settings?.class?.option] }, [ text(option.text), icon('radix-icons:caret-right', { width: 20, height: 20 }) ]), container: div('flex flex-1 flex-col', [div('flex flex-row justify-between items-center text-light-100 dark:text-dark-100 py-1 px-2 text-sm select-none sticky top-0 bg-light-20 dark:bg-dark-20 font-semibold', [button(icon('radix-icons:caret-left', { width: 16, height: 16 }), back, 'p-px'), text(option.text), div()]), div('flex flex-col flex-1', children.map(e => e?.dom))]), children };
return stored;
}
else
{
return { index: i, item: option, dom: dom('div', { listeners: { click: () => {
select.value = option.text;
settings?.change && settings?.change(option.value as T);
selected = true;
hide();
}, mouseenter: () => 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(option.text) ]) };
}
}
const filter = (value: string, option?: StoredOption<T>): HTMLElement[] => {
if(option && option.children !== undefined)
{
return option.children.flatMap(e => filter(value, e));
}
else if(option && option.item)
{
return option.item.text.toLowerCase().normalize().includes(value) ? [ option.dom ] : [];
}
else
{
return [];
}
}
let disabled = settings?.disabled ?? false;
const optionElements = options.map(render);
const select = dom('input', { listeners: { focus: show, input: () => {
context && select.value ? context.container.replaceChildren(...optionElements.flatMap(e => filter(select.value.toLowerCase().trim().normalize(), e))) : context.container.replaceChildren(...optionElements.filter(e => !!e).map(e => e.dom));
selected = false;
if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red')
}, keydown: (e) => {
const opt = (tree.slice(-1)[0]?.item?.value as Option<T>[] ?? options.filter(e => !!e)).filter(e => !!e), elements = (tree.slice(-1)[0]?.children ?? optionElements);
switch(e.key.toLocaleLowerCase())
{
case 'arrowdown':
focus(clamp((focused ?? -1) + 1, 0, opt.length - 1));
return;
case 'arrowup':
focus(clamp((focused ?? 1) - 1, 0, opt.length - 1));
return;
case 'pageup':
focus(0);
return;
case 'pagedown':
focus(opt.length - 1);
return;
case 'enter':
focused && elements[focused]?.dom.click();
return;
case 'escape':
context?.close();
return;
default: return;
}
} }, 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' });
settings?.defaultValue && Tree.each(options, 'value', (item) => { if(item.value === settings?.defaultValue) select.value = item.text });
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, placeholder?: string }): HTMLInputElement
{
const input = dom("input", { attributes: { disabled: settings?.disabled, placeholder: settings?.placeholder }, 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;
}
// Open by default
export function foldable(content: NodeChildren, title: NodeChildren, settings?: { open?: boolean, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } })
{
const fold = div(['group flex flex-1 w-full flex-col', settings?.class?.container], [
div('flex', [ dom('div', { listeners: { click: () => fold.toggleAttribute('data-active') }, class: ['flex justify-center items-center', settings?.class?.icon] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center' }) ]), div(['flex-1', settings?.class?.title], title) ]),
div(['hidden group-data-[active]:flex', settings?.class?.content], content),
]);
fold.toggleAttribute('data-active', settings?.open ?? true);
return fold;
}

View File

@ -52,6 +52,7 @@ export type CharacterConfig = {
aspects: AspectConfig[];
features: Record<FeatureID, Feature>;
lists: Record<string, { id: string, name: string, [key: string]: any }[]>;
texts: Record<string, Localized>;
};
export type SpellConfig = {
id: string;

5
types/general.d.ts vendored
View File

@ -14,3 +14,8 @@ type CanvasPreferences = {
spacing?: number;
neighborSnap: boolean;
};
export type Localized = {
fr_FR?: string;
en_US?: string;
default: string;
}