5180 fichiers 38.5 Mo 0 téléchargements 287 vues mis à jour il y a 6 heures ui
nitro-v36-with-renderer-v36 / src / components / wired / views / WiredValueOrVariableInput.tsx
WiredValueOrVariableInput.tsx 18.1 Ko Télécharger le fichier
import { FC, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { LocalizeText, localizeWithFallback } from '../../../api';
import contextVariableIcon from '../../../assets/images/wired/var/icon_source_context_clean.png';
import furniVariableIcon from '../../../assets/images/wired/var/icon_source_furni.png';
import globalVariableIcon from '../../../assets/images/wired/var/icon_source_global.png';
import userVariableIcon from '../../../assets/images/wired/var/icon_source_user.png';
import variableReferenceIcon from '../../../assets/images/wired/var/icon_variable_reference.png';
import { Text } from '../../../common';
import { WiredVariablePicker } from './WiredVariablePicker';
import { flattenWiredVariablePickerEntries, IWiredVariablePickerEntry, WiredVariablePickerTarget } from './WiredVariablePickerData';
import { BaseButton } from '../../../common/BaseButton';
import { Checkbox } from '../../../common/Checkbox';
import { Hint } from '../../../common/Tooltip';
import { NativeTextTree } from '../../../common/native-text/NativeTextTree';

/*
 * AIR WIN63-202610131318-860371703, roomevents/wired_setup/uibuilder/presets:
 * - sections/ValueOrVariableSection.as: one text field that holds a whole number or a variable name.
 * - ValueOrVariableInputPreset.as: the field, its target type indicator and the variable reference button.
 * - ValueOrVariablePopupPreset.as: the popup under the field with the claimed holder source, the
 *   "same furni or user" checkbox and the variable picker.
 */

export const WIRED_VALUE_TARGET_ICONS: Record<WiredVariablePickerTarget, string> = {
    furni: furniVariableIcon,
    user: userVariableIcon,
    global: globalVariableIcon,
    context: contextVariableIcon
};

/** WiredInputSourcePicker.getTypeNameForSource: the sourcetype text and icon name of a target. */
const TARGET_TYPE_NAMES: Record<WiredVariablePickerTarget, string> = { furni: 'furni', user: 'users', global: 'global', context: 'context' };

/** WiredInputSourcePicker.getColorForSourceType lightened by 1.26 (ValueOrVariableInputPreset.setTargetIndicator L120-121). */
const TARGET_GLOW_COLORS: Record<WiredVariablePickerTarget, string> = { furni: '#e5bc21', user: '#30b334', global: '#25a5b2', context: '#db7729' };

/** ValueOrVariableSection.isNumericText (L29-33, 98-101): decimal, 0x hex or 0b binary. */
const DECIMAL_PATTERN = /^-?\d+$/;
const HEX_PATTERN = /^0x[0-9a-fA-F]+$/;
const BINARY_PATTERN = /^0b[01]+$/;

export const isWiredNumericText = (text: string) => DECIMAL_PATTERN.test(text) || HEX_PATTERN.test(text) || BINARY_PATTERN.test(text);

/** ValueOrVariableSection.parseNumericText (L103-114). */
export const parseWiredNumericText = (text: string): number => {
    if (HEX_PATTERN.test(text)) return parseInt(text.substring(2), 16);
    if (BINARY_PATTERN.test(text)) return parseInt(text.substring(2), 2);

    return Number(text);
};

export type WiredValueTextResult =
    | { kind: 'literal'; valid: boolean; value?: number }
    | { kind: 'variable'; valid: boolean; ambiguous: boolean; entry?: IWiredVariablePickerEntry };

/**
 * ValueOrVariableSection.onInputTextChanged (L362-412): a number is a literal, in range and whole; "-" and "+" are an
 * unfinished literal; any other text is a variable name, valid when exactly one eligible variable has it (any case).
 */
export const readWiredValueText = (text: string, eligible: IWiredVariablePickerEntry[], min: number, max: number): WiredValueTextResult => {
    if (isWiredNumericText(text)) {
        const value = parseWiredNumericText(text);
        const valid = !Number.isNaN(value) && value === Math.floor(value) && value >= -2147483648 && value <= 2147483647 && value >= min && value <= max;

        return valid ? { kind: 'literal', valid, value } : { kind: 'literal', valid };
    }

    if (text === '-' || text === '+') return { kind: 'literal', valid: false };

    const lower = text.toLowerCase();
    const matches = eligible.filter((entry) => entry.displayLabel.toLowerCase() === lower);

    return matches.length === 1 ? { kind: 'variable', valid: true, ambiguous: false, entry: matches[0] } : { kind: 'variable', valid: false, ambiguous: matches.length > 1 };
};

/** The holder type buttons (AIR SourceTypeSelectorPreset). */
export const WiredVariableTargetButtons: FC<{
    targets: WiredVariablePickerTarget[];
    value: WiredVariablePickerTarget;
    disabled?: boolean;
    onChange: (target: WiredVariablePickerTarget) => void;
}> = ({ targets, value, disabled = false, onChange }) => (
    <div className="octane-wired__give-var-targets">
        {targets.map((target) => (
            <Hint key={target} content={LocalizeText(`wiredfurni.params.sourcetype.${TARGET_TYPE_NAMES[target]}`)}>
                <BaseButton
                    type="button"
                    disabled={disabled}
                    aria-label={LocalizeText(`wiredfurni.params.sourcetype.${TARGET_TYPE_NAMES[target]}`)}
                    className={`octane-wired__give-var-target octane-wired__give-var-target--${target} ${value === target ? 'is-active' : ''}`}
                    onClick={() => onChange(target)}
                >
                    <img src={WIRED_VALUE_TARGET_ICONS[target]} alt={target} />
                </BaseButton>
            </Hint>
        ))}
    </div>
);

export interface IWiredValueSourceRender {
    /** The popup's type selector, for the source section's header (ValueOrVariablePopupPreset.refreshTargetSelector L383-394). */
    typeSelector: ReactNode;
    /** The same holder locks the source (ValueOrVariablePopupPreset.applyReferenceVariableOnTargetState L354-362). */
    locked: boolean;
}

interface WiredValueOrVariableInputProps {
    /** The field's accessible name. */
    label: string;
    isVariable: boolean;
    literal: string;
    min?: number;
    max?: number;
    target: WiredVariablePickerTarget;
    /** The section's holder types (createValueOrVariableSection param2); one type hides the selector. */
    targets: WiredVariablePickerTarget[];
    token: string;
    entries: IWiredVariablePickerEntry[];
    disabled?: boolean;
    /**
     * ValueOrVariablePopupPreset.attachInputSource (L156-181): the claimed merged source as the popup's first section,
     * titled "wiredfurni.params.sources.merged.title.variables". Without it the type selector gets a section of its own.
     */
    renderSource?: (props: IWiredValueSourceRender) => ReactNode;
    /** The "use the same furni or user as the target variable" checkbox (ValueOrVariablePopupPreset L88-96). */
    sameHolder?: boolean;
    onSameHolderChange?: (checked: boolean) => void;
    /** The destination's holder type, which decides whether the same holder can be used (ValueOrVariableSection L461, 483-486). */
    primaryTarget?: WiredVariablePickerTarget;
    onIsVariableChange: (isVariable: boolean) => void;
    onLiteralChange: (literal: string) => void;
    onTargetChange: (target: WiredVariablePickerTarget) => void;
    onTokenChange: (token: string) => void;
    onValidityChange?: (valid: boolean) => void;
}

export const isWiredSameHolderEligible = (primaryTarget: WiredVariablePickerTarget | undefined, target: WiredVariablePickerTarget) =>
    (primaryTarget === 'furni' || primaryTarget === 'user') && primaryTarget === target;

export const WiredValueOrVariableInput: FC<WiredValueOrVariableInputProps> = (props) => {
    const {
        label,
        isVariable,
        literal,
        min = -2147483648,
        max = 2147483647,
        target,
        targets,
        token,
        entries,
        disabled = false,
        renderSource,
        sameHolder = false,
        onSameHolderChange,
        primaryTarget,
        onIsVariableChange,
        onLiteralChange,
        onTargetChange,
        onTokenChange,
        onValidityChange
    } = props;
    const leaves = useMemo(() => flattenWiredVariablePickerEntries(entries).filter((entry) => !entry.children?.length), [entries]);
    const eligible = useMemo(() => leaves.filter((entry) => entry.selectable), [leaves]);
    const selected = leaves.find((entry) => entry.token === token) ?? null;
    const sameHolderEligible = isWiredSameHolderEligible(primaryTarget, target);
    const sourceLocked = isVariable && sameHolder && sameHolderEligible;
    const wireKey = `${isVariable ? 1 : 0}:${literal}:${target}:${token}`;
    const publishedKey = useRef(wireKey);
    // The typed text and its reading; null shows the saved value (setTextSilently after init or a popup pick).
    const [edit, setEdit] = useState<{ text: string; valid: boolean; ambiguous: boolean } | null>(null);
    const [popupRect, setPopupRect] = useState<{ left: number; top: number; width: number } | null>(null);
    const anchorRef = useRef<HTMLDivElement>(null);
    const popupRef = useRef<HTMLDivElement>(null);
    const portalTarget = typeof document !== 'undefined' ? (document.getElementById('draggable-windows-container') ?? document.body) : null;
    const open = !!popupRect && !disabled;

    const shownText = isVariable ? (selected?.displayLabel ?? '') : literal;
    const text = edit ? edit.text : shownText;
    // ValueOrVariableSection.init L160 / applyVariableTarget L518-522: a variable reads valid only when it can be picked.
    const valid = edit ? edit.valid : !isVariable || (!!selected && selected.selectable);
    const ambiguous = edit ? edit.ambiguous : false;
    const showError = !disabled && !valid;

    useEffect(() => {
        if (publishedKey.current === wireKey) return;

        publishedKey.current = wireKey;
        setEdit(null);
    }, [wireKey]);

    useEffect(() => {
        onValidityChange?.(disabled || valid);
    }, [disabled, valid, onValidityChange]);

    // ValueOrVariablePopupPreset.refreshSourceVisibility L216-220: a disabled section hides the popup.
    useEffect(() => {
        if (disabled) setPopupRect(null);
    }, [disabled]);

    // NewVariablePicker.onDeactivate: a click outside the popup and the field hides it.
    useEffect(() => {
        if (!open) return;

        const onMouseDown = (event: globalThis.MouseEvent) => {
            const node = event.target as Node;

            if (popupRef.current?.contains(node) || anchorRef.current?.contains(node)) return;
            if ((node as HTMLElement)?.closest?.('.octane-wired__vpicker-list')) return;

            setPopupRect(null);
        };

        document.addEventListener('mousedown', onMouseDown);

        return () => document.removeEventListener('mousedown', onMouseDown);
    }, [open]);

    const publish = (nextIsVariable: boolean, nextLiteral: string, nextToken: string) => {
        publishedKey.current = `${nextIsVariable ? 1 : 0}:${nextLiteral}:${target}:${nextToken}`;
    };

    const onChangeText = (next: string) => {
        const result = readWiredValueText(next, eligible, min, max);

        if (result.kind === 'literal') {
            const nextLiteral = result.valid ? String(result.value) : literal;

            publish(false, nextLiteral, token);
            setEdit({ text: next, valid: result.valid, ambiguous: false });
            onIsVariableChange(false);
            if (result.valid) onLiteralChange(nextLiteral);

            return;
        }

        const nextToken = result.entry ? result.entry.token : token;

        publish(true, literal, nextToken);
        setEdit({ text: next, valid: result.valid, ambiguous: result.ambiguous });
        onIsVariableChange(true);
        if (result.entry) onTokenChange(result.entry.token);
    };

    const toggle = () => {
        if (open) {
            setPopupRect(null);
            return;
        }

        const rect = anchorRef.current?.getBoundingClientRect();

        // NewVariablePicker.expandView with an external anchor (L362-368): the anchor's width, from its bottom edge.
        setPopupRect(rect ? { left: rect.left, top: rect.bottom, width: Math.round(rect.width) } : { left: 0, top: 0, width: 230 });
    };

    const changeTarget = (next: WiredVariablePickerTarget) => {
        if (next === target || sourceLocked) return;

        onTargetChange(next);
        onTokenChange('');
    };

    const typeSelector = targets.length > 1 ? <WiredVariableTargetButtons targets={targets} value={target} disabled={sourceLocked} onChange={changeTarget} /> : null;
    const errorText = ambiguous
        ? localizeWithFallback('wiredfurni.params.variables.reference_value.ambiguous', 'More than one variable has this name.')
        : localizeWithFallback('wiredfurni.params.variables.reference_value.invalid', 'Enter a valid whole number or variable name.');
    // ValueOrVariableSection.updateTargetIndicator (L561-564): only for a variable, and only with a choice of types.
    const showTargetIndicator = isVariable && targets.length > 1;

    return (
        <NativeTextTree>
            <div className="octane-wired__value-field">
                <div ref={anchorRef} className={`octane-wired__value-input ${showError ? 'is-invalid' : ''} ${disabled ? 'is-disabled' : ''}`}>
                    <input aria-label={label} aria-invalid={showError} disabled={disabled} type="text" value={text} onChange={(event) => onChangeText(event.target.value)} />
                    {showTargetIndicator && (
                        <span className="octane-wired__value-input-target" title={LocalizeText(`wiredfurni.params.sourcetype.${TARGET_TYPE_NAMES[target]}`)}>
                            <img alt={target} draggable={false} src={WIRED_VALUE_TARGET_ICONS[target]} style={{ filter: `drop-shadow(0 0 2px ${TARGET_GLOW_COLORS[target]})` }} />
                        </span>
                    )}
                    <Hint content={LocalizeText('wiredfurni.params.variables.reference_value.popup')}>
                        <BaseButton
                            aria-expanded={open}
                            aria-label={LocalizeText('wiredfurni.params.variables.reference_value.popup')}
                            className="octane-wired__value-input-button"
                            disabled={disabled}
                            type="button"
                            onClick={toggle}
                        >
                            <img alt="" draggable={false} src={variableReferenceIcon} />
                        </BaseButton>
                    </Hint>
                    {showError && (
                        <div className="octane-wired__value-input-error" role="alert">
                            {errorText}
                        </div>
                    )}
                </div>
                {open &&
                    portalTarget &&
                    createPortal(
                        <div className="octane-wired octane-wired__variable-picker-portal">
                            <div
                                ref={popupRef}
                                aria-label={label}
                                className="octane-wired__vpicker-expanded octane-wired__value-popup"
                                role="dialog"
                                style={{ left: popupRect.left, top: popupRect.top, width: popupRect.width }}
                            >
                                <div className="octane-wired__value-popup-header">
                                    {renderSource ? (
                                        <>
                                            <div className="octane-wired__value-popup-source">{renderSource({ typeSelector, locked: sourceLocked })}</div>
                                            <div className="octane-wired__divider" />
                                        </>
                                    ) : (
                                        typeSelector && (
                                            // Without a claimed source the type selector is a section of its own, with no splitter (L396-412).
                                            <div className="octane-wired__value-popup-source octane-wired__value-popup-target">
                                                <Text>{localizeWithFallback('wiredfurni.params.variables.reference_value.target', LocalizeText('wiredfurni.params.sources.merged.title.variables'))}</Text>
                                                {typeSelector}
                                            </div>
                                        )
                                    )}
                                    {onSameHolderChange && (
                                        <>
                                            <label className={`octane-wired__value-popup-same-holder ${sameHolderEligible ? '' : 'is-disabled'}`}>
                                                <Checkbox checked={sameHolder} disabled={!sameHolderEligible} onCheckedChange={ nextChecked => {
                                                        onSameHolderChange(nextChecked);
                                                        // onReferenceVariableOnTargetChanged -> onPopupVariableChanged(selected, checked) (L443-451, 421-424).
                                                        if (nextChecked) onIsVariableChange(true);
                                                    } } />
                                                <Text>{LocalizeText('wiredfurni.params.variables.reference_value.same_holder')}</Text>
                                            </label>
                                            <div className="octane-wired__divider" />
                                        </>
                                    )}
                                </div>
                                <WiredVariablePicker
                                    embedded={{ width: popupRect.width, onClose: () => setPopupRect(null) }}
                                    entries={entries}
                                    selectedToken={token}
                                    onSelect={(entry) => {
                                        publish(true, literal, entry.token);
                                        setEdit(null);
                                        onIsVariableChange(true);
                                        onTokenChange(entry.token);
                                        setPopupRect(null);
                                    }}
                                />
                            </div>
                        </div>,
                        portalTarget
                    )}
            </div>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser