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:
parent
247b14b2c8
commit
69ee62c08e
3
bun.lock
3
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { hasPermissions } from '~/shared/auth.util';
|
||||
|
||||
|
||||
const { path } = defineProps<{
|
||||
path: string
|
||||
filter?: string,
|
||||
|
|
|
|||
|
|
@ -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-shm
BIN
db.sqlite-shm
Binary file not shown.
BIN
db.sqlite-wal
BIN
db.sqlite-wal
Binary file not shown.
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -106,5 +106,5 @@ function _useSession(event: H3Event) {
|
|||
|
||||
sessionConfig = runtimeConfig.session;
|
||||
}
|
||||
return useSession<UserSession>(event, sessionConfig)
|
||||
return useSession<UserSession>(event, sessionConfig);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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[]>, };
|
||||
|
|
|
|||
|
|
@ -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)))) ]);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
295
shared/proses.ts
295
shared/proses.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -14,3 +14,8 @@ type CanvasPreferences = {
|
|||
spacing?: number;
|
||||
neighborSnap: boolean;
|
||||
};
|
||||
export type Localized = {
|
||||
fr_FR?: string;
|
||||
en_US?: string;
|
||||
default: string;
|
||||
}
|
||||
Loading…
Reference in New Issue