import {
useEffect,
useMemo,
useRef,
useState,
type InputHTMLAttributes,
type MouseEvent,
} from 'react';
import { Braces, Clock3, Search, X } from 'lucide-react';
import { ModalPortal } from './modal-portal';
import {
RICH_TEXT_VARIABLE_CATALOG,
RICH_TEXT_VARIABLE_CATEGORY_LABELS,
type RichTextVariableDefinition,
} from './rich-text-variables';
import { Input } from './ui';
export function richTextVariableExpression(key: string): string {
const trimmed = key.trim();
if (!trimmed) return '';
if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed;
return `{${trimmed.replace(/^\{+|\}+$/g, '')}}`;
}
export function variableInsertionExpression(item: RichTextVariableDefinition): string {
const example = item.example?.trim() ?? '';
return /^\{[^{}]+\}$/.test(example) ? example : richTextVariableExpression(item.key);
}
export function variableIdentity(key: string): string {
return richTextVariableExpression(key).slice(1, -1);
}
const TIME_PATTERN_MAX_RUN: Readonly<Record<string, number>> = {
u: 19, y: 19, M: 5, d: 2, H: 2, h: 2, m: 2, s: 2, S: 9,
E: 5, e: 5, a: 1, X: 5, x: 5, Z: 5, z: 4,
};
/** Browser equivalent of TriggerTimeContext.validatePattern's bounded Java-time subset. */
function timePatternIsValid(pattern: string): boolean {
if (!pattern.trim() || pattern.length > 64) return false;
let quoted = false;
let optionalDepth = 0;
for (let index = 0; index < pattern.length; index += 1) {
const value = pattern[index];
const code = value.charCodeAt(0);
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || value === '{' || value === '}') return false;
if (value === "'") {
if (pattern[index + 1] === "'") index += 1;
else quoted = !quoted;
continue;
}
if (quoted) continue;
if (value === '[') {
optionalDepth += 1;
continue;
}
if (value === ']') {
if (optionalDepth === 0) return false;
optionalDepth -= 1;
continue;
}
if (value === '#') return false;
if (!/[A-Za-z]/.test(value)) continue;
if (value === 'Y' || value === 'D' || TIME_PATTERN_MAX_RUN[value] === undefined) return false;
let runEnd = index + 1;
while (pattern[runEnd] === value) runEnd += 1;
if (runEnd - index > TIME_PATTERN_MAX_RUN[value]) return false;
index = runEnd - 1;
}
return !quoted && optionalDepth === 0;
}
/** Mirrors TriggerEvaluator.validateTemplateVariables for both plain and encoded rich text. */
export function templateTimeFormatsAreValid(template: string): boolean {
for (const prefix of ['{server.time:', '{event.time:']) {
let offset = 0;
while (true) {
const start = template.indexOf(prefix, offset);
if (start < 0) break;
const close = template.indexOf('}', start + prefix.length);
if (close < 0 || !timePatternIsValid(template.slice(start + prefix.length, close))) return false;
offset = close + 1;
}
}
return true;
}
function dynamicVariableDefinition(expression: string): RichTextVariableDefinition {
const key = variableIdentity(expression);
const commandArgument = key.startsWith('args.');
return {
key,
nameZh: commandArgument ? `指令参数 ${key.slice(5)}` : `当前事件变量 ${key}`,
nameEn: commandArgument ? `Command argument ${key.slice(5)}` : `Current event variable ${key}`,
descriptionZh: commandArgument
? '来自当前玩家指令触发方式中声明并按空格解析的参数。'
: '由当前触发事件提供的动态变量;仅在该事件具备对应值时可用。',
descriptionEn: commandArgument
? 'Declared by the current player-command trigger and parsed as a space-separated argument.'
: 'Dynamic variable supplied by the current trigger event when that context is available.',
category: commandArgument ? 'command' : 'event',
scopes: commandArgument ? ['player.command_trigger'] : ['current-event'],
example: expression,
};
}
/** Merge the offline catalog, an authoritative server catalog, and current-event variables. */
export function mergeRichTextVariableCatalog(
catalog: readonly RichTextVariableDefinition[] = [],
variables: readonly string[] = [],
): RichTextVariableDefinition[] {
const merged = new Map<string, RichTextVariableDefinition>();
const authoritativeCatalog = catalog.length > 0 ? catalog : RICH_TEXT_VARIABLE_CATALOG;
for (const item of authoritativeCatalog) {
if (item.sensitive === true || item.templateAllowed === false) continue;
const identity = variableIdentity(item.key);
if (!identity) continue;
const previous = merged.get(identity);
merged.set(identity, {
...previous,
...item,
key: identity,
scopes: item.scopes?.length ? item.scopes : previous?.scopes ?? ['global'],
});
}
for (const expression of variables) {
const identity = variableIdentity(expression);
if (identity && !merged.has(identity)) merged.set(identity, dynamicVariableDefinition(expression));
}
return [...merged.values()];
}
const SCOPE_LABELS: Readonly<Record<string, string>> = {
'*': '全部事件 / All events',
'player.*': '所有玩家事件 / All player events',
global: '全局 / Global',
world: '世界上下文 / World context',
'player-event': '玩家事件 / Player event',
event: '事件上下文 / Event context',
'event-specific': '特定事件 / Event specific',
command: '指令上下文 / Command context',
'current-event': '当前事件 / Current event',
'player.command_trigger': '玩家指令 / Player command',
schedule: '定时触发 / Schedule',
'block-event': '方块事件 / Block event',
'entity-event': '实体事件 / Entity event',
'item-event': '物品事件 / Item event',
'damage-event': '伤害事件 / Damage event',
};
const VARIABLE_TYPE_LABELS: Readonly<Record<string, string>> = {
string: '文本 / String',
number: '数值 / Number',
boolean: '布尔值 / Boolean',
instant: '时间点 / Instant',
datetime: '日期时间 / Date & time',
};
function searchableVariableText(item: RichTextVariableDefinition): string {
const category = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item.category];
return [item.key, richTextVariableExpression(item.key), item.nameZh, item.nameEn,
item.descriptionZh, item.descriptionEn, category?.zh, category?.en,
item.example, item.sampleValue, item.type, item.formatHint, ...item.scopes,
...(item.events ?? [])].filter(Boolean).join(' ').toLocaleLowerCase();
}
export function VariableCatalogDialog({ variables, onSelect, onClose, selectionHint }: {
variables: readonly RichTextVariableDefinition[];
onSelect: (variable: RichTextVariableDefinition) => void;
onClose: () => void;
selectionHint?: string;
}) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
const filteredVariables = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return variables.filter((item) => (
(category === 'all' || item.category === category)
&& (!normalizedQuery || searchableVariableText(item).includes(normalizedQuery))
));
}, [category, query, variables]);
const categories = useMemo(() => [...new Set(variables.map((item) => item.category))], [variables]);
const categoryCounts = useMemo(() => variables.reduce<Record<string, number>>((counts, item) => {
counts[item.category] = (counts[item.category] ?? 0) + 1;
return counts;
}, {}), [variables]);
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onClose();
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [onClose]);
return <ModalPortal>
<div className="variable-browser-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<section className="variable-browser" role="dialog" aria-modal="true"
aria-label="插入变量 / Insert variable">
<header className="variable-browser__header">
<div><span><Braces size={15} />变量目录 / Variable catalog</span>
<small>{variables.length} 个可插入变量 · {selectionHint ?? '点击任意条目插入到原光标或选区'}</small></div>
<button type="button" aria-label="关闭变量目录 / Close variable catalog"
onClick={onClose}><X size={18} /></button>
</header>
<div className="variable-browser__format-help" role="note">
<Clock3 size={17} />
<div><strong>服务器时间格式 / Server time format</strong>
<p>推荐 <code>{'{server.time:uuuu-MM-dd HH:mm:ss}'}</code>。<code>MM</code> 是月份,
<code>mm</code> 是分钟,<code>HH</code> 是 24 小时制;<code>YYYY</code> 是周历年,
跨年附近会产生歧义且服务端不接受,请使用 <code>uuuu</code>(<code>yyyy</code> 也可用)。
日期请用 <code>dd</code>;<code>DD</code> 是年内日序,同样不接受。</p>
<small>Recommended pattern: uuuu-MM-dd HH:mm:ss. Pattern letters are case-sensitive.</small>
</div>
</div>
<div className="variable-browser__workspace">
<nav className="variable-browser__categories" aria-label="变量分类 / Variable categories">
<strong>变量分类<small>Categories</small></strong>
<button type="button" className={category === 'all' ? 'is-active' : ''}
aria-current={category === 'all' ? 'page' : undefined} onClick={() => setCategory('all')}>
<span>全部<small>All</small></span><em>{variables.length}</em>
</button>
{categories.map((item) => {
const label = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item];
return <button type="button" key={item} className={category === item ? 'is-active' : ''}
aria-current={category === item ? 'page' : undefined} onClick={() => setCategory(item)}>
<span>{label?.zh ?? item}<small>{label?.en ?? item}</small></span>
<em>{categoryCounts[item] ?? 0}</em>
</button>;
})}
</nav>
<div className="variable-browser__catalog">
<div className="variable-browser__filters">
<label className="variable-browser__search"><Search size={15} /><Input type="search" autoFocus
aria-label="搜索变量 / Search variables" value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索名称、代码或说明 / Search name, code, or description" /></label>
</div>
<div className="variable-browser__summary" aria-live="polite">
显示 {filteredVariables.length} / {variables.length} · Showing {filteredVariables.length} of {variables.length}
</div>
<div className="variable-browser__results">
{filteredVariables.map((item) => {
const expression = variableInsertionExpression(item);
const itemCategory = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item.category];
return <button type="button" className="variable-browser__item" key={variableIdentity(item.key)}
aria-label={`插入变量 ${expression}:${item.nameZh} / ${item.nameEn}`}
onClick={() => onSelect(item)}>
<span className="variable-browser__item-heading"><span><strong>{item.nameZh}</strong>
<small>{item.nameEn}</small></span><code>{expression}</code></span>
<span className="variable-browser__item-description">{item.descriptionZh}<small>{item.descriptionEn}</small></span>
<span className="variable-browser__item-meta">
<span>{itemCategory ? `${itemCategory.zh} / ${itemCategory.en}` : item.category}</span>
{item.scopes.map((scope) => <span key={scope}>{SCOPE_LABELS[scope] ?? scope}</span>)}
{item.events?.some((event) => event !== '*') && <span
title={item.events.join(', ')}>适用 / Events: {item.events.join(', ')}</span>}
{item.type && <span>类型 / Type: {VARIABLE_TYPE_LABELS[item.type] ?? item.type}</span>}
{item.sampleValue && <span>示例值 / Sample: <code>{item.sampleValue}</code></span>}
</span>
{item.formatHint && <span className="variable-browser__item-hint">格式提示 / Format: {item.formatHint}</span>}
</button>;
})}
{filteredVariables.length === 0 && <div className="variable-browser__empty">
<Search size={22} /><strong>没有匹配的变量 / No matching variables</strong>
<span>请尝试名称、变量代码、用途或分类关键词。</span>
</div>}
</div>
</div>
</div>
</section>
</div>
</ModalPortal>;
}
interface VariableTextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value: string;
onValueChange: (value: string) => void;
parameter: string;
variables?: readonly string[];
variableCatalog?: readonly RichTextVariableDefinition[];
zh?: boolean;
}
/** Plain text input with a variable catalog that inserts at the saved browser selection. */
export function VariableTextInput({
value,
onValueChange,
parameter,
variables = [],
variableCatalog = [],
zh = true,
...inputProps
}: VariableTextInputProps) {
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const launcherRef = useRef<HTMLButtonElement>(null);
const selection = useRef({ start: value.length, end: value.length });
const availableVariables = useMemo(
() => mergeRichTextVariableCatalog(variableCatalog, variables),
[variableCatalog, variables],
);
const rememberSelection = () => {
const input = inputRef.current;
if (!input) return;
selection.current = {
start: input.selectionStart ?? value.length,
end: input.selectionEnd ?? input.selectionStart ?? value.length,
};
};
const close = () => {
setOpen(false);
globalThis.requestAnimationFrame?.(() => launcherRef.current?.focus());
};
const insert = (item: RichTextVariableDefinition) => {
const start = Math.max(0, Math.min(selection.current.start, value.length));
const end = Math.max(start, Math.min(selection.current.end, value.length));
const expression = variableInsertionExpression(item);
const next = `${value.slice(0, start)}${expression}${value.slice(end)}`;
const caret = start + expression.length;
selection.current = { start: caret, end: caret };
onValueChange(next);
setOpen(false);
globalThis.requestAnimationFrame?.(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(caret, caret);
});
};
const label = zh ? `为 ${parameter} 插入变量` : `Insert variable into ${parameter}`;
return <div className="variable-text-input">
<input {...inputProps} ref={inputRef} className={`input ${inputProps.className ?? ''}`}
aria-label={inputProps['aria-label'] ?? parameter} value={value}
onChange={(event) => {
onValueChange(event.target.value);
selection.current = {
start: event.target.selectionStart ?? event.target.value.length,
end: event.target.selectionEnd ?? event.target.selectionStart ?? event.target.value.length,
};
}}
onSelect={(event) => {
rememberSelection();
inputProps.onSelect?.(event);
}}
onKeyUp={(event) => {
rememberSelection();
inputProps.onKeyUp?.(event);
}} />
<button ref={launcherRef} type="button" className="variable-text-input__button"
aria-label={`${label} / ${zh ? `Insert variable into ${parameter}` : `为 ${parameter} 插入变量`}`}
title={label} aria-haspopup="dialog" aria-expanded={open}
onMouseDown={(event: MouseEvent<HTMLButtonElement>) => { rememberSelection(); event.preventDefault(); }}
onClick={() => setOpen(true)}><Braces size={15} /><span>{zh ? '插入变量' : 'Variable'}</span></button>
{open && <VariableCatalogDialog variables={availableVariables} onClose={close} onSelect={insert}
selectionHint={zh ? `点击变量插入到 ${parameter} 的原光标或选区`
: `Select a variable to insert it at the saved ${parameter} cursor or selection`} />}
</div>;
}
import {
useEffect,
useMemo,
useRef,
useState,
type InputHTMLAttributes,
type MouseEvent,
} from 'react';
import { Braces, Clock3, Search, X } from 'lucide-react';
import { ModalPortal } from './modal-portal';
import {
RICH_TEXT_VARIABLE_CATALOG,
RICH_TEXT_VARIABLE_CATEGORY_LABELS,
type RichTextVariableDefinition,
} from './rich-text-variables';
import { Input } from './ui';
export function richTextVariableExpression(key: string): string {
const trimmed = key.trim();
if (!trimmed) return '';
if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed;
return `{${trimmed.replace(/^\{+|\}+$/g, '')}}`;
}
export function variableInsertionExpression(item: RichTextVariableDefinition): string {
const example = item.example?.trim() ?? '';
return /^\{[^{}]+\}$/.test(example) ? example : richTextVariableExpression(item.key);
}
export function variableIdentity(key: string): string {
return richTextVariableExpression(key).slice(1, -1);
}
const TIME_PATTERN_MAX_RUN: Readonly<Record<string, number>> = {
u: 19, y: 19, M: 5, d: 2, H: 2, h: 2, m: 2, s: 2, S: 9,
E: 5, e: 5, a: 1, X: 5, x: 5, Z: 5, z: 4,
};
/** Browser equivalent of TriggerTimeContext.validatePattern's bounded Java-time subset. */
function timePatternIsValid(pattern: string): boolean {
if (!pattern.trim() || pattern.length > 64) return false;
let quoted = false;
let optionalDepth = 0;
for (let index = 0; index < pattern.length; index += 1) {
const value = pattern[index];
const code = value.charCodeAt(0);
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || value === '{' || value === '}') return false;
if (value === "'") {
if (pattern[index + 1] === "'") index += 1;
else quoted = !quoted;
continue;
}
if (quoted) continue;
if (value === '[') {
optionalDepth += 1;
continue;
}
if (value === ']') {
if (optionalDepth === 0) return false;
optionalDepth -= 1;
continue;
}
if (value === '#') return false;
if (!/[A-Za-z]/.test(value)) continue;
if (value === 'Y' || value === 'D' || TIME_PATTERN_MAX_RUN[value] === undefined) return false;
let runEnd = index + 1;
while (pattern[runEnd] === value) runEnd += 1;
if (runEnd - index > TIME_PATTERN_MAX_RUN[value]) return false;
index = runEnd - 1;
}
return !quoted && optionalDepth === 0;
}
/** Mirrors TriggerEvaluator.validateTemplateVariables for both plain and encoded rich text. */
export function templateTimeFormatsAreValid(template: string): boolean {
for (const prefix of ['{server.time:', '{event.time:']) {
let offset = 0;
while (true) {
const start = template.indexOf(prefix, offset);
if (start < 0) break;
const close = template.indexOf('}', start + prefix.length);
if (close < 0 || !timePatternIsValid(template.slice(start + prefix.length, close))) return false;
offset = close + 1;
}
}
return true;
}
function dynamicVariableDefinition(expression: string): RichTextVariableDefinition {
const key = variableIdentity(expression);
const commandArgument = key.startsWith('args.');
return {
key,
nameZh: commandArgument ? `指令参数 ${key.slice(5)}` : `当前事件变量 ${key}`,
nameEn: commandArgument ? `Command argument ${key.slice(5)}` : `Current event variable ${key}`,
descriptionZh: commandArgument
? '来自当前玩家指令触发方式中声明并按空格解析的参数。'
: '由当前触发事件提供的动态变量;仅在该事件具备对应值时可用。',
descriptionEn: commandArgument
? 'Declared by the current player-command trigger and parsed as a space-separated argument.'
: 'Dynamic variable supplied by the current trigger event when that context is available.',
category: commandArgument ? 'command' : 'event',
scopes: commandArgument ? ['player.command_trigger'] : ['current-event'],
example: expression,
};
}
/** Merge the offline catalog, an authoritative server catalog, and current-event variables. */
export function mergeRichTextVariableCatalog(
catalog: readonly RichTextVariableDefinition[] = [],
variables: readonly string[] = [],
): RichTextVariableDefinition[] {
const merged = new Map<string, RichTextVariableDefinition>();
const authoritativeCatalog = catalog.length > 0 ? catalog : RICH_TEXT_VARIABLE_CATALOG;
for (const item of authoritativeCatalog) {
if (item.sensitive === true || item.templateAllowed === false) continue;
const identity = variableIdentity(item.key);
if (!identity) continue;
const previous = merged.get(identity);
merged.set(identity, {
...previous,
...item,
key: identity,
scopes: item.scopes?.length ? item.scopes : previous?.scopes ?? ['global'],
});
}
for (const expression of variables) {
const identity = variableIdentity(expression);
if (identity && !merged.has(identity)) merged.set(identity, dynamicVariableDefinition(expression));
}
return [...merged.values()];
}
const SCOPE_LABELS: Readonly<Record<string, string>> = {
'*': '全部事件 / All events',
'player.*': '所有玩家事件 / All player events',
global: '全局 / Global',
world: '世界上下文 / World context',
'player-event': '玩家事件 / Player event',
event: '事件上下文 / Event context',
'event-specific': '特定事件 / Event specific',
command: '指令上下文 / Command context',
'current-event': '当前事件 / Current event',
'player.command_trigger': '玩家指令 / Player command',
schedule: '定时触发 / Schedule',
'block-event': '方块事件 / Block event',
'entity-event': '实体事件 / Entity event',
'item-event': '物品事件 / Item event',
'damage-event': '伤害事件 / Damage event',
};
const VARIABLE_TYPE_LABELS: Readonly<Record<string, string>> = {
string: '文本 / String',
number: '数值 / Number',
boolean: '布尔值 / Boolean',
instant: '时间点 / Instant',
datetime: '日期时间 / Date & time',
};
function searchableVariableText(item: RichTextVariableDefinition): string {
const category = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item.category];
return [item.key, richTextVariableExpression(item.key), item.nameZh, item.nameEn,
item.descriptionZh, item.descriptionEn, category?.zh, category?.en,
item.example, item.sampleValue, item.type, item.formatHint, ...item.scopes,
...(item.events ?? [])].filter(Boolean).join(' ').toLocaleLowerCase();
}
export function VariableCatalogDialog({ variables, onSelect, onClose, selectionHint }: {
variables: readonly RichTextVariableDefinition[];
onSelect: (variable: RichTextVariableDefinition) => void;
onClose: () => void;
selectionHint?: string;
}) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
const filteredVariables = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return variables.filter((item) => (
(category === 'all' || item.category === category)
&& (!normalizedQuery || searchableVariableText(item).includes(normalizedQuery))
));
}, [category, query, variables]);
const categories = useMemo(() => [...new Set(variables.map((item) => item.category))], [variables]);
const categoryCounts = useMemo(() => variables.reduce<Record<string, number>>((counts, item) => {
counts[item.category] = (counts[item.category] ?? 0) + 1;
return counts;
}, {}), [variables]);
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onClose();
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [onClose]);
return <ModalPortal>
<div className="variable-browser-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<section className="variable-browser" role="dialog" aria-modal="true"
aria-label="插入变量 / Insert variable">
<header className="variable-browser__header">
<div><span><Braces size={15} />变量目录 / Variable catalog</span>
<small>{variables.length} 个可插入变量 · {selectionHint ?? '点击任意条目插入到原光标或选区'}</small></div>
<button type="button" aria-label="关闭变量目录 / Close variable catalog"
onClick={onClose}><X size={18} /></button>
</header>
<div className="variable-browser__format-help" role="note">
<Clock3 size={17} />
<div><strong>服务器时间格式 / Server time format</strong>
<p>推荐 <code>{'{server.time:uuuu-MM-dd HH:mm:ss}'}</code>。<code>MM</code> 是月份,
<code>mm</code> 是分钟,<code>HH</code> 是 24 小时制;<code>YYYY</code> 是周历年,
跨年附近会产生歧义且服务端不接受,请使用 <code>uuuu</code>(<code>yyyy</code> 也可用)。
日期请用 <code>dd</code>;<code>DD</code> 是年内日序,同样不接受。</p>
<small>Recommended pattern: uuuu-MM-dd HH:mm:ss. Pattern letters are case-sensitive.</small>
</div>
</div>
<div className="variable-browser__workspace">
<nav className="variable-browser__categories" aria-label="变量分类 / Variable categories">
<strong>变量分类<small>Categories</small></strong>
<button type="button" className={category === 'all' ? 'is-active' : ''}
aria-current={category === 'all' ? 'page' : undefined} onClick={() => setCategory('all')}>
<span>全部<small>All</small></span><em>{variables.length}</em>
</button>
{categories.map((item) => {
const label = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item];
return <button type="button" key={item} className={category === item ? 'is-active' : ''}
aria-current={category === item ? 'page' : undefined} onClick={() => setCategory(item)}>
<span>{label?.zh ?? item}<small>{label?.en ?? item}</small></span>
<em>{categoryCounts[item] ?? 0}</em>
</button>;
})}
</nav>
<div className="variable-browser__catalog">
<div className="variable-browser__filters">
<label className="variable-browser__search"><Search size={15} /><Input type="search" autoFocus
aria-label="搜索变量 / Search variables" value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索名称、代码或说明 / Search name, code, or description" /></label>
</div>
<div className="variable-browser__summary" aria-live="polite">
显示 {filteredVariables.length} / {variables.length} · Showing {filteredVariables.length} of {variables.length}
</div>
<div className="variable-browser__results">
{filteredVariables.map((item) => {
const expression = variableInsertionExpression(item);
const itemCategory = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item.category];
return <button type="button" className="variable-browser__item" key={variableIdentity(item.key)}
aria-label={`插入变量 ${expression}:${item.nameZh} / ${item.nameEn}`}
onClick={() => onSelect(item)}>
<span className="variable-browser__item-heading"><span><strong>{item.nameZh}</strong>
<small>{item.nameEn}</small></span><code>{expression}</code></span>
<span className="variable-browser__item-description">{item.descriptionZh}<small>{item.descriptionEn}</small></span>
<span className="variable-browser__item-meta">
<span>{itemCategory ? `${itemCategory.zh} / ${itemCategory.en}` : item.category}</span>
{item.scopes.map((scope) => <span key={scope}>{SCOPE_LABELS[scope] ?? scope}</span>)}
{item.events?.some((event) => event !== '*') && <span
title={item.events.join(', ')}>适用 / Events: {item.events.join(', ')}</span>}
{item.type && <span>类型 / Type: {VARIABLE_TYPE_LABELS[item.type] ?? item.type}</span>}
{item.sampleValue && <span>示例值 / Sample: <code>{item.sampleValue}</code></span>}
</span>
{item.formatHint && <span className="variable-browser__item-hint">格式提示 / Format: {item.formatHint}</span>}
</button>;
})}
{filteredVariables.length === 0 && <div className="variable-browser__empty">
<Search size={22} /><strong>没有匹配的变量 / No matching variables</strong>
<span>请尝试名称、变量代码、用途或分类关键词。</span>
</div>}
</div>
</div>
</div>
</section>
</div>
</ModalPortal>;
}
interface VariableTextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value: string;
onValueChange: (value: string) => void;
parameter: string;
variables?: readonly string[];
variableCatalog?: readonly RichTextVariableDefinition[];
zh?: boolean;
}
/** Plain text input with a variable catalog that inserts at the saved browser selection. */
export function VariableTextInput({
value,
onValueChange,
parameter,
variables = [],
variableCatalog = [],
zh = true,
...inputProps
}: VariableTextInputProps) {
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const launcherRef = useRef<HTMLButtonElement>(null);
const selection = useRef({ start: value.length, end: value.length });
const availableVariables = useMemo(
() => mergeRichTextVariableCatalog(variableCatalog, variables),
[variableCatalog, variables],
);
const rememberSelection = () => {
const input = inputRef.current;
if (!input) return;
selection.current = {
start: input.selectionStart ?? value.length,
end: input.selectionEnd ?? input.selectionStart ?? value.length,
};
};
const close = () => {
setOpen(false);
globalThis.requestAnimationFrame?.(() => launcherRef.current?.focus());
};
const insert = (item: RichTextVariableDefinition) => {
const start = Math.max(0, Math.min(selection.current.start, value.length));
const end = Math.max(start, Math.min(selection.current.end, value.length));
const expression = variableInsertionExpression(item);
const next = `${value.slice(0, start)}${expression}${value.slice(end)}`;
const caret = start + expression.length;
selection.current = { start: caret, end: caret };
onValueChange(next);
setOpen(false);
globalThis.requestAnimationFrame?.(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(caret, caret);
});
};
const label = zh ? `为 ${parameter} 插入变量` : `Insert variable into ${parameter}`;
return <div className="variable-text-input">
<input {...inputProps} ref={inputRef} className={`input ${inputProps.className ?? ''}`}
aria-label={inputProps['aria-label'] ?? parameter} value={value}
onChange={(event) => {
onValueChange(event.target.value);
selection.current = {
start: event.target.selectionStart ?? event.target.value.length,
end: event.target.selectionEnd ?? event.target.selectionStart ?? event.target.value.length,
};
}}
onSelect={(event) => {
rememberSelection();
inputProps.onSelect?.(event);
}}
onKeyUp={(event) => {
rememberSelection();
inputProps.onKeyUp?.(event);
}} />
<button ref={launcherRef} type="button" className="variable-text-input__button"
aria-label={`${label} / ${zh ? `Insert variable into ${parameter}` : `为 ${parameter} 插入变量`}`}
title={label} aria-haspopup="dialog" aria-expanded={open}
onMouseDown={(event: MouseEvent<HTMLButtonElement>) => { rememberSelection(); event.preventDefault(); }}
onClick={() => setOpen(true)}><Braces size={15} /><span>{zh ? '插入变量' : 'Variable'}</span></button>
{open && <VariableCatalogDialog variables={availableVariables} onClose={close} onSelect={insert}
selectionHint={zh ? `点击变量插入到 ${parameter} 的原光标或选区`
: `Select a variable to insert it at the saved ${parameter} cursor or selection`} />}
</div>;
}