Rollback CharacterEditor to the previous version

This commit is contained in:
2026-06-10 13:29:33 +02:00
parent f9e0473b2a
commit bc1839c5e3
14 changed files with 1864 additions and 959 deletions

View File

@@ -1,10 +1,10 @@
import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router";
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node, span } from "#shared/dom";
import { contextmenu, followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "#shared/floating";
import { clamp, deepEquals, shallowEquals } from "#shared/general";
import { type NodeProperties, type Class, type NodeChildren, dom, text, div, icon, type Node, span } from "#shared/dom";
import { followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "#shared/floating";
import { clamp, shallowEquals } from "#shared/general";
import { Tree } from "#shared/tree";
import type { Placement } from "@floating-ui/dom";
import { reactivity, type Reactive } from '#shared/reactive';
import { reactivity, type Reactive, reactive } from '#shared/reactive';
export function link(children: NodeChildren, properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>)
{
@@ -84,8 +84,10 @@ export function optionmenu(options: Array<{ title: string, click: () => void }>,
close = followermenu(target ?? this, [ element ], { arrow: true, placement: settings?.position, offset: 8 }).close;
}
}
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 type Option<T> = { text: string, value: T, render?: () => HTMLElement } | undefined;
export type RecurrentOption<T> = { text: string, render?: () => HTMLElement, value: T | RecurrentOption<T>[] } | undefined;
type StoredRecurrentOption<T> = { item: RecurrentOption<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredRecurrentOption<T>> };
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement };
export function select<T extends NonNullable<any>>(options: Reactive<Array<{ text: string, value: T } | undefined>>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLDivElement & { disabled: boolean, value: T | undefined }
{
let context: { close: Function }, _options: Array<{ text: string, value: T }> = [], optionElements: HTMLElement[] = [];
@@ -171,7 +173,7 @@ export function select<T extends NonNullable<any>>(options: Reactive<Array<{ tex
change = settings?.change;
return select;
}
export function multiselect<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLDivElement & { disabled: boolean, value: T[] | undefined }
export function multiselect<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLDivElement & { disabled: boolean, value: T[] | undefined }
{
let context: { close: Function }, _options: Array<{ text: string, value: T }> = [], optionElements: HTMLElement[] = [];
let focused: number | undefined, value: T[] | undefined, valueText: Text = text(''), disabled: boolean, change: ((v: T[]) => void) | undefined = undefined;
@@ -328,14 +330,14 @@ export function multiselect<T extends NonNullable<any>>(options: Array<{ text: s
})
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' })
export function combobox<T extends NonNullable<any>>(options: RecurrentOption<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' })
{
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true, tree: StoredOption<T>[] = [];
let selected = true, tree: StoredRecurrentOption<T>[] = [];
let focused: number | undefined;
let currentOptions: StoredOption<T>[] = [];
let currentOptions: StoredRecurrentOption<T>[] = [];
const focus = (value?: T | Option<T>[]) => {
const focus = (value?: T | RecurrentOption<T>[]) => {
focused !== undefined && currentOptions[focused]?.dom.toggleAttribute('data-focused', false);
if(value !== undefined)
{
@@ -371,7 +373,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
context && context.container.parentElement && context.close();
};
const progress = (option: StoredOption<T>) => {
const progress = (option: StoredRecurrentOption<T>) => {
if(!context || !context.container.parentElement || option.container === undefined)
return;
@@ -387,7 +389,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
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 => {
const render = (option: RecurrentOption<T>): StoredRecurrentOption<T> | undefined => {
if(option === undefined)
return;
@@ -403,7 +405,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
return { item: option, dom: dom('div', { listeners: { click: (_e) => { container.value = option.value as T; !_e.ctrlKey && 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>[] => {
const filter = (value: string, option?: StoredRecurrentOption<T>): StoredRecurrentOption<T>[] => {
if(option && option.children !== undefined)
{
return option.children.flatMap(e => filter(value, e));
@@ -484,9 +486,124 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
}
return container;
}
export function tagpicker<T extends NonNullable<any>>(options: Reactive<Option<T>[]>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' })
{
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
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();
currentOptions = optionElements.map(e => filter(select.value.toLowerCase().trim().normalize(), e)).filter(e => !!e);
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 });
};
const hide = () => {
context && context.container.parentElement && context.close();
select.value = "";
};
const add = (value: T) => {
if(container.value.includes(value)) return;
container.value = [...container.value, value];
};
const remove = (value: T) => {
container.value = container.value.filter(e => e !== value);
}
const render = (option: Option<T>): StoredOption<T> | undefined => {
if(option === undefined)
return;
return { item: option, dom: dom('div', { listeners: { click: (_e) => { add(option.value); !_e.ctrlKey && 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> | undefined => {
return option?.item?.text.toLowerCase().normalize().includes(_value) && !value.includes(option.item.value) ? option : undefined;
}
let disabled = settings?.disabled ?? false, optionElements: StoredOption<T>[] = [];
reactivity(options, (_options) => {
optionElements = currentOptions = _options.map(render).filter(e => !!e);
})
const select = dom('input', { listeners: { focus: show, input: () => {
focus();
currentOptions = context && select.value ? optionElements.map(e => filter(select.value.toLowerCase().trim().normalize(), e)).filter(e => !!e) : optionElements.filter(e => !!e);
context && context.container.replaceChildren(...currentOptions.map(e => e.dom));
}, 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;
case 'backspace':
if(select.value === '')
currentOptions[currentOptions.length - 1]?.item?.value && remove(currentOptions[currentOptions.length - 1]!.item!.value);
return;
default: return;
}
} }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-10 dark:bg-dark-10 disabled:bg-light-20 dark:disabled:bg-dark-20 w-full' });
let value: T[] = reactive([]);
const container = dom('label', { class: ['inline-flex h-10 w-full outline-none px-1 items-center justify-between text-sm font-semibold leading-none 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 overflow-y-hidden overflow-x-auto no-scroll', settings?.class?.container] }, [ div('flex flex-row gap-2 item-center select-none py-1 flex-shrink-0', { list: () => value, render: (v, _c) => _c ?? div('flex flex-row gap-2 items-center border border-light-35 dark:border-dark-35 py-1 ps-2 pe-1', [ span('text-sm', currentOptions.find(e => e.item?.value === v)?.item?.text), dom('span', { listeners: { click: () => remove(v) } }, [ icon('radix-icons:cross-1', { width: 10, height: 10, class: 'cursor-pointer text-light-60 dark:text-dark-60 hover:text-light-100 dark:hover:text-dark-100' }) ]) ]) }), select ]) as HTMLLabelElement & { disabled: boolean, value: T[] };
Object.defineProperty(container, 'disabled', {
get: () => disabled,
set: (v) => {
disabled = !!v;
container.toggleAttribute('data-disabled', disabled);
select.toggleAttribute('disabled', disabled);
},
})
Object.defineProperty(container, 'value', {
get: () => value,
set: (v: T[] | undefined) => {
settings?.change && value !== v && settings?.change(v as T[]);
value.splice(0, value.length, ...(v ?? []));
},
});
if(settings?.defaultValue) value.splice(0, value.length, ...settings?.defaultValue);
return container;
}
export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void | boolean, 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
const input = dom("input", { attributes: { disabled: settings?.disabled, placeholder: settings?.placeholder, type: type }, 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: (e) => { if(settings?.input && settings.input(input.value) === false) input.value = value; else value = input.value; },
@@ -821,14 +938,6 @@ export function floater(container: HTMLElement, content: NodeChildren | (() => N
return container;
}
export function tagpicker<T extends string>(list?: Reactive<T[]>, settings?: { defaultValue?: T[], class?: { container?: string, tag?: string }, disabled?: Reactive<boolean>, onUpdate?: () => boolean, onAdd?: (pick: T) => boolean, onRemove?: (pick: T) => boolean })
{
const value = settings?.defaultValue ?? [];
const _input = dom('input', { class: 'appearence-none bg-transparent border-none outline-none', attributes: { type: 'text', tabindex: '0' }, listeners: { focus: () => content.toggleAttribute('data-focused', true), blur: () => content.toggleAttribute('data-focused', false) }}), float = followermenu(_input, [ div('flex flex-col', { list: list, render: (item, _c) => _c ?? text(item) }) ]);
const content = dom('div', { class: ['border border-light-35 dark:border-dark-35 p-2 gap-2 flex flex-row items-center', settings?.class?.container], attributes: { tabindex: '0' }, listeners: { focus: () => _input.focus(), click: () => _input.focus() } }, [ div('flex flex-row gap-2', { list: () => value, render: (restrict, _c) => _c ?? div('') }), _input ]);
return content;
}
export interface ToastConfig
{