import { Bold, Braces, Eye, EyeOff, Italic, Link2, Sparkles,
Strikethrough, Underline } from 'lucide-react';
import {
useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type CompositionEvent,
type FormEvent, type KeyboardEvent, type MouseEvent,
} from 'react';
import type { RichTextVariableDefinition } from './rich-text-variables';
import {
mergeRichTextVariableCatalog,
VariableCatalogDialog,
variableInsertionExpression,
} from './variable-catalog-picker';
import { Field, Input, Select, Textarea } from './ui';
export { RICH_TEXT_VARIABLE_CATALOG } from './rich-text-variables';
export type { RichTextVariableDefinition } from './rich-text-variables';
export { mergeRichTextVariableCatalog, richTextVariableExpression } from './variable-catalog-picker';
export const RICH_TEXT_PREFIX = '@xfesm-rich:v1:';
export const MAX_RICH_TEXT_ENCODED_CHARACTERS = 65_536;
export const MAX_RICH_TEXT_CHARACTERS = 8192;
export const MAX_RICH_TEXT_SEGMENT_CHARACTERS = 2048;
export const MAX_RICH_TEXT_SEGMENTS = 256;
export const DEFAULT_MESSAGE_SENDER = 'XFEServerManager';
export const DEFAULT_MESSAGE_NUMBER_PRECISION = 2;
export const MAX_MESSAGE_NUMBER_PRECISION = 10;
export const RICH_TEXT_CLIPBOARD_MIME = 'application/x-xfeservermanager-rich-text+json';
const RICH_TEXT_CLIPBOARD_FRAGMENT_LIMIT = MAX_RICH_TEXT_SEGMENTS;
const RICH_TEXT_CLIPBOARD_FRAGMENT_TTL_MS = 10 * 60 * 1000;
const RICH_TEXT_CLIPBOARD_ENVELOPE_CHARACTERS = 128;
export type RichClickAction = 'none' | 'open_url' | 'run_command' | 'suggest_command' | 'copy_to_clipboard';
export interface RichTextSegment {
text: string;
color: string;
bold: boolean;
italic: boolean;
underlined: boolean;
strikethrough: boolean;
obfuscated: boolean;
insertion: string;
clickAction: RichClickAction;
clickValue: string;
hoverText: string;
}
export interface RichTextSender {
visible: boolean;
text: string;
color: string;
bold: boolean;
italic: boolean;
underlined: boolean;
strikethrough: boolean;
}
export interface RichTextDocument {
version: 1;
/** Fraction digits used for floating-point variables in player-facing text. */
numberPrecision: number;
/** Missing means that the server-wide default sender should be used. */
sender?: RichTextSender;
segments: RichTextSegment[];
}
const DEFAULT_SEGMENT: RichTextSegment = {
text: '', color: '#ffffff', bold: false, italic: false, underlined: false,
strikethrough: false, obfuscated: false, insertion: '', clickAction: 'none', clickValue: '', hoverText: '',
};
const DEFAULT_SENDER: RichTextSender = {
visible: true, text: DEFAULT_MESSAGE_SENDER, color: '#71e6a1', bold: false,
italic: false, underlined: false, strikethrough: false,
};
function segment(value?: Partial<RichTextSegment>): RichTextSegment {
return { ...DEFAULT_SEGMENT, ...value };
}
function sender(value?: Partial<RichTextSender>): RichTextSender {
return { ...DEFAULT_SENDER, ...value };
}
function safeColor(value: unknown, fallback: string): string {
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : fallback;
}
export function decodeRichTextDocument(value: string): RichTextDocument {
if (!value.startsWith(RICH_TEXT_PREFIX)) {
return { version: 1, numberPrecision: DEFAULT_MESSAGE_NUMBER_PRECISION,
segments: [segment({ text: value })] };
}
try {
const parsed = JSON.parse(value.slice(RICH_TEXT_PREFIX.length)) as Partial<RichTextDocument>;
const parsedSender = parsed.sender;
const segments = Array.isArray(parsed.segments) && parsed.segments.length > 0
? parsed.segments.slice(0, MAX_RICH_TEXT_SEGMENTS).map((item) => segment({
text: typeof item.text === 'string' ? item.text : '',
color: safeColor(item.color, DEFAULT_SEGMENT.color),
bold: item.bold === true,
italic: item.italic === true,
underlined: item.underlined === true,
strikethrough: item.strikethrough === true,
obfuscated: item.obfuscated === true,
insertion: typeof item.insertion === 'string' ? item.insertion : '',
clickAction: ['open_url', 'run_command', 'suggest_command', 'copy_to_clipboard'].includes(item.clickAction ?? '')
? item.clickAction as RichClickAction : 'none',
clickValue: typeof item.clickValue === 'string' ? item.clickValue : '',
hoverText: typeof item.hoverText === 'string' ? item.hoverText : '',
})) : [segment()];
return {
version: 1,
numberPrecision: Number.isInteger(parsed.numberPrecision)
&& (parsed.numberPrecision ?? -1) >= 0 && (parsed.numberPrecision ?? 11) <= MAX_MESSAGE_NUMBER_PRECISION
? parsed.numberPrecision as number : DEFAULT_MESSAGE_NUMBER_PRECISION,
...(parsedSender && typeof parsedSender === 'object' ? { sender: sender({
visible: parsedSender.visible !== false,
text: typeof parsedSender.text === 'string' ? parsedSender.text : DEFAULT_SENDER.text,
color: safeColor(parsedSender.color, DEFAULT_SENDER.color),
bold: parsedSender.bold === true,
italic: parsedSender.italic === true,
underlined: parsedSender.underlined === true,
strikethrough: parsedSender.strikethrough === true,
}) } : {}),
segments,
};
} catch {
return { version: 1, numberPrecision: DEFAULT_MESSAGE_NUMBER_PRECISION,
segments: [segment({ text: value })] };
}
}
export function decodeRichText(value: string): RichTextSegment[] {
return decodeRichTextDocument(value).segments;
}
export function encodeRichText(segments: RichTextSegment[], messageSender?: RichTextSender,
numberPrecision = DEFAULT_MESSAGE_NUMBER_PRECISION): string {
const document: RichTextDocument = {
version: 1,
numberPrecision: Math.max(0, Math.min(MAX_MESSAGE_NUMBER_PRECISION, Math.trunc(numberPrecision))),
...(messageSender ? { sender: sender(messageSender) } : {}),
segments: segments.slice(0, MAX_RICH_TEXT_SEGMENTS).map((item) => segment(item)),
};
return RICH_TEXT_PREFIX + JSON.stringify(document);
}
export function richTextPlainText(value: string): string {
return decodeRichText(value).map((item) => item.text).join('');
}
function clickValueError(item: RichTextSegment): string | undefined {
if (item.clickAction === 'open_url') {
try {
const target = new URL(item.clickValue);
if ((target.protocol === 'http:' || target.protocol === 'https:') && target.hostname) return undefined;
} catch {
// Fall through to the actionable editor error below.
}
return '网页地址必须是完整的 http:// 或 https:// URL / URL must use http:// or https://';
}
if ((item.clickAction === 'run_command' || item.clickAction === 'suggest_command')
&& !item.clickValue.startsWith('/')) {
return '命令点击内容必须以 / 开头 / Command click value must start with /';
}
return undefined;
}
export function richTextValidationErrors(value: string): string[] {
const document = decodeRichTextDocument(value);
const errors = document.segments.map(clickValueError).filter((error): error is string => Boolean(error));
if (value.length > MAX_RICH_TEXT_ENCODED_CHARACTERS) {
errors.unshift(`富文本编码不能超过 ${MAX_RICH_TEXT_ENCODED_CHARACTERS} 个字符 / Encoded rich text cannot exceed ${MAX_RICH_TEXT_ENCODED_CHARACTERS} characters`);
}
if (document.segments.some((item) => item.text.length > MAX_RICH_TEXT_SEGMENT_CHARACTERS)) {
errors.unshift(`单个文本段不能超过 ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} 个字符 / A segment cannot exceed ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} characters`);
}
const totalCharacters = document.segments.reduce((total, item) => total + item.text.length, 0);
if (totalCharacters > MAX_RICH_TEXT_CHARACTERS) {
errors.unshift(`文本总长度不能超过 ${MAX_RICH_TEXT_CHARACTERS} 个字符 / Total text cannot exceed ${MAX_RICH_TEXT_CHARACTERS} characters`);
}
return errors;
}
export function richTextIsValid(value: string): boolean {
return richTextValidationErrors(value).length === 0;
}
function textDecoration(value: { underlined: boolean; strikethrough: boolean }): string | undefined {
return [value.underlined && 'underline', value.strikethrough && 'line-through'].filter(Boolean).join(' ') || undefined;
}
interface TextSelection { start: number; end: number }
interface NativeEventChainGuard {
inputTypes: readonly string[];
timer: ReturnType<typeof globalThis.setTimeout>;
}
interface PendingComposition {
baseSegments: RichTextSegment[];
baseText: string;
data: string;
endSelection: TextSelection;
endText: string;
timer: ReturnType<typeof globalThis.setTimeout>;
}
interface RichTextClipboardFragment {
createdAt: number;
plain: string;
runs: RichTextSegment[];
}
const richTextClipboardFragments = new Map<string, RichTextClipboardFragment>();
interface RichTextClipboardEnvelope {
version: 1;
id: string;
}
interface CompositionBase {
segments: RichTextSegment[];
text: string;
}
interface DeferredExternalValue {
value: string;
}
interface CompositionTailSnapshot {
selection: TextSelection;
text: string;
}
function segmentAttributesEqual(left: RichTextSegment, right: RichTextSegment): boolean {
return left.color === right.color && left.bold === right.bold && left.italic === right.italic
&& left.underlined === right.underlined && left.strikethrough === right.strikethrough
&& left.obfuscated === right.obfuscated && left.insertion === right.insertion
&& left.clickAction === right.clickAction && left.clickValue === right.clickValue
&& left.hoverText === right.hoverText;
}
function splitSegmentText(item: RichTextSegment): RichTextSegment[] {
if (!item.text) return [];
const result: RichTextSegment[] = [];
let offset = 0;
while (offset < item.text.length) {
let end = Math.min(offset + MAX_RICH_TEXT_SEGMENT_CHARACTERS, item.text.length);
if (end < item.text.length && /[\uD800-\uDBFF]/.test(item.text[end - 1])
&& /[\uDC00-\uDFFF]/.test(item.text[end])) end -= 1;
result.push({ ...item, text: item.text.slice(offset, end) });
offset = end;
}
return result;
}
function normalizeRuns(items: RichTextSegment[]): RichTextSegment[] {
const populated = items.flatMap(splitSegmentText);
if (!populated.length) return [segment(items[0])];
return populated.reduce<RichTextSegment[]>((result, item) => {
const previous = result.at(-1);
if (previous && segmentAttributesEqual(previous, item)
&& previous.text.length + item.text.length <= MAX_RICH_TEXT_SEGMENT_CHARACTERS) {
previous.text += item.text;
} else {
result.push({ ...item });
}
return result;
}, []);
}
function documentText(segments: RichTextSegment[]): string {
return segments.map((item) => item.text).join('');
}
function clampSelection(value: TextSelection, length: number): TextSelection {
const start = Math.max(0, Math.min(value.start, value.end, length));
const end = Math.max(start, Math.min(Math.max(value.start, value.end), length));
return { start, end };
}
function sliceRuns(items: RichTextSegment[], from: number, to: number): RichTextSegment[] {
const result: RichTextSegment[] = [];
let offset = 0;
items.forEach((item) => {
const itemStart = offset;
const itemEnd = offset + item.text.length;
offset = itemEnd;
const start = Math.max(from, itemStart);
const end = Math.min(to, itemEnd);
if (start < end) result.push({ ...item, text: item.text.slice(start - itemStart, end - itemStart) });
});
return result;
}
function runAtOffset(items: RichTextSegment[], requestedOffset: number): RichTextSegment {
const total = documentText(items).length;
const target = Math.max(0, Math.min(requestedOffset, total));
let offset = 0;
let previous: RichTextSegment | undefined;
for (const item of items) {
if (!item.text) continue;
if (target === offset) return previous ?? item;
if (target < offset + item.text.length) return item;
offset += item.text.length;
previous = item;
}
return previous ?? items[0] ?? segment();
}
function replaceTextRange(items: RichTextSegment[], selection: TextSelection, replacement: string): RichTextSegment[] {
const total = documentText(items).length;
const range = clampSelection(selection, total);
const template = runAtOffset(items, range.start);
const next = [
...sliceRuns(items, 0, range.start),
...(replacement ? [{ ...template, text: replacement }] : []),
...sliceRuns(items, range.end, total),
];
return normalizeRuns(next.length ? next : [{ ...template, text: '' }]);
}
function replaceRunsRange(items: RichTextSegment[], selection: TextSelection,
replacements: RichTextSegment[]): RichTextSegment[] {
const total = documentText(items).length;
const range = clampSelection(selection, total);
const template = runAtOffset(items, range.start);
const next = [
...sliceRuns(items, 0, range.start),
...replacements.map((item) => ({ ...item })),
...sliceRuns(items, range.end, total),
];
return normalizeRuns(next.length ? next : [{ ...template, text: '' }]);
}
function updateTextRange(items: RichTextSegment[], selection: TextSelection,
change: Partial<Omit<RichTextSegment, 'text'>>): RichTextSegment[] {
const total = documentText(items).length;
const range = clampSelection(selection, total);
if (range.start === range.end) return items;
const result: RichTextSegment[] = [];
let offset = 0;
items.forEach((item) => {
const itemStart = offset;
const itemEnd = offset + item.text.length;
offset = itemEnd;
if (!item.text || itemEnd <= range.start || itemStart >= range.end) {
result.push(item);
return;
}
const selectedStart = Math.max(range.start, itemStart) - itemStart;
const selectedEnd = Math.min(range.end, itemEnd) - itemStart;
if (selectedStart > 0) result.push({ ...item, text: item.text.slice(0, selectedStart) });
result.push({ ...item, ...change, text: item.text.slice(selectedStart, selectedEnd) });
if (selectedEnd < item.text.length) result.push({ ...item, text: item.text.slice(selectedEnd) });
});
return normalizeRuns(result);
}
function runsInRange(items: RichTextSegment[], selection: TextSelection): RichTextSegment[] {
const range = clampSelection(selection, documentText(items).length);
let offset = 0;
return items.filter((item) => {
const itemStart = offset;
const itemEnd = offset + item.text.length;
offset = itemEnd;
return item.text.length > 0 && itemEnd > range.start && itemStart < range.end;
});
}
function selectionOffsets(root: HTMLElement): TextSelection | undefined {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return undefined;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return undefined;
const beforeStart = document.createRange();
beforeStart.selectNodeContents(root);
beforeStart.setEnd(range.startContainer, range.startOffset);
const beforeEnd = document.createRange();
beforeEnd.selectNodeContents(root);
beforeEnd.setEnd(range.endContainer, range.endOffset);
return clampSelection({ start: beforeStart.toString().length, end: beforeEnd.toString().length }, root.textContent?.length ?? 0);
}
function textNodeAtOffset(root: HTMLElement, requestedOffset: number): { node: Node; offset: number } {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let remaining = Math.max(0, requestedOffset);
let last: Text | undefined;
for (let current = walker.nextNode() as Text | null; current; current = walker.nextNode() as Text | null) {
last = current;
if (remaining <= current.data.length) return { node: current, offset: remaining };
remaining -= current.data.length;
}
return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 };
}
function restoreSelection(root: HTMLElement, selection: TextSelection): void {
const range = clampSelection(selection, root.textContent?.length ?? 0);
const start = textNodeAtOffset(root, range.start);
const end = textNodeAtOffset(root, range.end);
const domRange = document.createRange();
domRange.setStart(start.node, start.offset);
domRange.setEnd(end.node, end.offset);
const domSelection = window.getSelection();
domSelection?.removeAllRanges();
domSelection?.addRange(domRange);
}
function renderEditorRuns(root: HTMLElement, items: RichTextSegment[]): void {
const fragment = document.createDocumentFragment();
items.forEach((item, index) => {
if (!item.text) return;
const run = document.createElement('span');
run.dataset.richRun = String(index);
if (item.hoverText) run.title = item.hoverText;
run.style.color = item.color;
if (item.bold) run.style.fontWeight = '700';
if (item.italic) run.style.fontStyle = 'italic';
const decoration = textDecoration(item);
if (decoration) run.style.textDecoration = decoration;
if (item.obfuscated) run.style.filter = 'blur(2px)';
run.textContent = item.text;
fragment.append(run);
});
root.replaceChildren(fragment);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function copyRichTextRuns(items: RichTextSegment[]): RichTextSegment[] {
return items.map((item) => ({ ...item }));
}
function pruneRichTextClipboardFragments(now: number): void {
for (const [id, fragment] of richTextClipboardFragments) {
if (now - fragment.createdAt > RICH_TEXT_CLIPBOARD_FRAGMENT_TTL_MS) richTextClipboardFragments.delete(id);
}
while (richTextClipboardFragments.size >= RICH_TEXT_CLIPBOARD_FRAGMENT_LIMIT) {
const oldest = richTextClipboardFragments.keys().next().value as string | undefined;
if (!oldest) break;
richTextClipboardFragments.delete(oldest);
}
}
function registerRichTextClipboardFragment(items: RichTextSegment[], plain: string): string | undefined {
const cryptoApi = globalThis.crypto;
if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return undefined;
const runs = normalizeRuns(copyRichTextRuns(items));
if (documentText(runs) !== plain || !richTextIsValid(encodeRichText(runs))) return undefined;
try {
const now = Date.now();
pruneRichTextClipboardFragments(now);
for (let attempt = 0; attempt < 4; attempt += 1) {
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
const id = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
if (richTextClipboardFragments.has(id)) continue;
richTextClipboardFragments.set(id, { createdAt: now, plain, runs });
return JSON.stringify({ version: 1, id } satisfies RichTextClipboardEnvelope);
}
} catch {
// Sandboxed or legacy browsers can deny cryptographic randomness. In that case
// copy remains standards-compatible plain text and no metadata leaves the page.
}
return undefined;
}
function resolveRichTextClipboardFragment(value: string, plain: string): RichTextSegment[] | undefined {
if (!value || value.length > RICH_TEXT_CLIPBOARD_ENVELOPE_CHARACTERS) return undefined;
try {
const parsed: unknown = JSON.parse(value);
if (!isRecord(parsed) || parsed.version !== 1 || typeof parsed.id !== 'string'
|| !/^[0-9a-f]{32}$/.test(parsed.id) || Object.keys(parsed).some((key) => key !== 'version' && key !== 'id')) {
return undefined;
}
const fragment = richTextClipboardFragments.get(parsed.id);
if (!fragment || Date.now() - fragment.createdAt > RICH_TEXT_CLIPBOARD_FRAGMENT_TTL_MS
|| fragment.plain !== plain || documentText(fragment.runs) !== plain) {
if (fragment) richTextClipboardFragments.delete(parsed.id);
return undefined;
}
return copyRichTextRuns(fragment.runs);
} catch {
return undefined;
}
}
function changedTextRange(previous: string, next: string): { selection: TextSelection; replacement: string } {
let start = 0;
while (start < previous.length && start < next.length && previous[start] === next[start]) start += 1;
let previousEnd = previous.length;
let nextEnd = next.length;
while (previousEnd > start && nextEnd > start && previous[previousEnd - 1] === next[nextEnd - 1]) {
previousEnd -= 1;
nextEnd -= 1;
}
return { selection: { start, end: previousEnd }, replacement: next.slice(start, nextEnd) };
}
function commonRunValue<K extends keyof RichTextSegment>(items: RichTextSegment[], key: K): RichTextSegment[K] | undefined {
const first = items[0]?.[key];
return items.length > 0 && items.every((item) => item[key] === first) ? first : undefined;
}
export function RichTextEditor({ value, onChange, compact = false,
defaultSenderName = DEFAULT_MESSAGE_SENDER, variables = [], variableCatalog = [] }: {
value: string;
onChange: (value: string) => void;
compact?: boolean;
defaultSenderName?: string;
variables?: string[];
variableCatalog?: readonly RichTextVariableDefinition[];
}) {
const richDocument = decodeRichTextDocument(value);
const { segments, sender: messageSender, numberPrecision } = richDocument;
const plainText = documentText(segments);
const totalCharacters = plainText.length;
const [senderOpen, setSenderOpen] = useState(!compact);
const [limitError, setLimitError] = useState('');
const [variableBrowserOpen, setVariableBrowserOpen] = useState(false);
const [selectionState, setSelectionState] = useState<TextSelection>({ start: totalCharacters, end: totalCharacters });
const [editorRevision, setEditorRevision] = useState(0);
const editorRef = useRef<HTMLDivElement>(null);
const variableBrowserButtonRef = useRef<HTMLButtonElement>(null);
const selectionRef = useRef<TextSelection>({ start: totalCharacters, end: totalCharacters });
const pendingSelection = useRef<TextSelection | undefined>(undefined);
const pendingSelectionFocus = useRef(false);
const composing = useRef(false);
const compositionBase = useRef<CompositionBase | undefined>(undefined);
const pendingComposition = useRef<PendingComposition | undefined>(undefined);
const nativeEventChainGuard = useRef<NativeEventChainGuard | undefined>(undefined);
const ignoreNextCompositionEnd = useRef(false);
const ignoreCancelledCompositionInput = useRef(false);
const ignoreCompositionTimer = useRef<ReturnType<typeof globalThis.setTimeout> | undefined>(undefined);
const deferredExternalValue = useRef<DeferredExternalValue | undefined>(undefined);
const beforeInputHandler = useRef<(event: globalThis.InputEvent) => void>(() => undefined);
const latestSegments = useRef(segments);
const latestText = useRef(plainText);
const latestValue = useRef(value);
const observedPropValue = useRef(value);
const renderedRunsKey = useRef<string | undefined>(undefined);
const renderedRevision = useRef(-1);
const runsRenderKey = JSON.stringify(segments);
if (observedPropValue.current !== value) {
observedPropValue.current = value;
if (composing.current || pendingComposition.current) deferredExternalValue.current = { value };
else if (latestValue.current !== value) {
latestSegments.current = segments;
latestText.current = plainText;
latestValue.current = value;
}
}
const currentSelection = clampSelection(selectionState, totalCharacters);
const selectedRuns = runsInRange(segments, currentSelection);
const hasSelection = currentSelection.end > currentSelection.start;
const inheritsDefaultSender = messageSender === undefined;
const effectiveDefaultSender = defaultSenderName.trim() || DEFAULT_MESSAGE_SENDER;
const previewSender = messageSender
? { ...messageSender, text: messageSender.text.trim() || effectiveDefaultSender }
: sender({ text: effectiveDefaultSender });
const availableVariables = mergeRichTextVariableCatalog(variableCatalog, variables);
const validationErrors = [...new Set(richTextValidationErrors(value))];
const selectedStyle = {
bold: selectedRuns.length > 0 && selectedRuns.every((item) => item.bold),
italic: selectedRuns.length > 0 && selectedRuns.every((item) => item.italic),
underlined: selectedRuns.length > 0 && selectedRuns.every((item) => item.underlined),
strikethrough: selectedRuns.length > 0 && selectedRuns.every((item) => item.strikethrough),
obfuscated: selectedRuns.length > 0 && selectedRuns.every((item) => item.obfuscated),
};
const selectedColor = commonRunValue(selectedRuns, 'color');
const selectedClickAction = commonRunValue(selectedRuns, 'clickAction');
const selectedClickValue = commonRunValue(selectedRuns, 'clickValue');
const selectedHoverText = commonRunValue(selectedRuns, 'hoverText');
const selectedInsertion = commonRunValue(selectedRuns, 'insertion');
const selectedClickSample = selectedClickAction && selectedClickAction !== 'none'
? { ...(selectedRuns[0] ?? DEFAULT_SEGMENT), clickAction: selectedClickAction,
clickValue: selectedClickValue ?? '' }
: undefined;
const commit = (rawSegments = segments, options: {
sender?: RichTextSender; numberPrecision?: number; normalize?: boolean;
} = {}): boolean => {
const nextSegments = options.normalize ? normalizeRuns(rawSegments) : rawSegments;
const nextSender = Object.prototype.hasOwnProperty.call(options, 'sender') ? options.sender : messageSender;
const nextNumberPrecision = Object.prototype.hasOwnProperty.call(options, 'numberPrecision')
? options.numberPrecision ?? DEFAULT_MESSAGE_NUMBER_PRECISION : numberPrecision;
const nextTotal = nextSegments.reduce((total, item) => total + item.text.length, 0);
if (nextSegments.length > MAX_RICH_TEXT_SEGMENTS) {
setLimitError(`富文本样式段不能超过 ${MAX_RICH_TEXT_SEGMENTS} 个 / Rich text cannot exceed ${MAX_RICH_TEXT_SEGMENTS} style runs`);
return false;
}
if (nextSegments.some((item) => item.text.length > MAX_RICH_TEXT_SEGMENT_CHARACTERS)) {
setLimitError(`单个文本段不能超过 ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} 个字符 / A segment cannot exceed ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} characters`);
return false;
}
if (nextTotal > MAX_RICH_TEXT_CHARACTERS) {
setLimitError(`文本总长度不能超过 ${MAX_RICH_TEXT_CHARACTERS} 个字符 / Total text cannot exceed ${MAX_RICH_TEXT_CHARACTERS} characters`);
return false;
}
const encoded = encodeRichText(nextSegments, nextSender, nextNumberPrecision);
if (encoded.length > MAX_RICH_TEXT_ENCODED_CHARACTERS) {
setLimitError(`富文本编码不能超过 ${MAX_RICH_TEXT_ENCODED_CHARACTERS} 个字符 / Encoded rich text cannot exceed ${MAX_RICH_TEXT_ENCODED_CHARACTERS} characters`);
return false;
}
setLimitError('');
latestSegments.current = nextSegments;
latestText.current = documentText(nextSegments);
latestValue.current = encoded;
onChange(encoded);
return true;
};
const rememberSelection = () => {
const root = editorRef.current;
if (!root) return;
const next = selectionOffsets(root);
if (!next) return;
selectionRef.current = next;
if (!composing.current) setSelectionState((current) => current.start === next.start && current.end === next.end
? current : next);
};
useEffect(() => {
const listener = () => rememberSelection();
globalThis.document.addEventListener('selectionchange', listener);
return () => globalThis.document.removeEventListener('selectionchange', listener);
});
useLayoutEffect(() => {
const root = editorRef.current;
if (!root) return;
let forceRebuild = false;
if (composing.current || pendingComposition.current) {
if (!deferredExternalValue.current) return;
composing.current = false;
compositionBase.current = undefined;
ignoreNextCompositionEnd.current = true;
ignoreCancelledCompositionInput.current = true;
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
ignoreCompositionTimer.current = globalThis.setTimeout(() => {
ignoreCancelledCompositionInput.current = false;
ignoreCompositionTimer.current = undefined;
}, 0);
if (pendingComposition.current) globalThis.clearTimeout(pendingComposition.current.timer);
pendingComposition.current = undefined;
if (nativeEventChainGuard.current) globalThis.clearTimeout(nativeEventChainGuard.current.timer);
nativeEventChainGuard.current = undefined;
deferredExternalValue.current = undefined;
latestSegments.current = segments;
latestText.current = plainText;
latestValue.current = value;
const externalSelection = clampSelection(selectionRef.current, plainText.length);
selectionRef.current = externalSelection;
setSelectionState(externalSelection);
pendingSelection.current = externalSelection;
pendingSelectionFocus.current = globalThis.document.activeElement === root;
forceRebuild = true;
}
const next = pendingSelection.current ?? selectionOffsets(root);
const shouldFocus = pendingSelectionFocus.current || globalThis.document.activeElement === root;
if (forceRebuild || renderedRunsKey.current !== runsRenderKey || renderedRevision.current !== editorRevision) {
renderEditorRuns(root, segments);
renderedRunsKey.current = runsRenderKey;
renderedRevision.current = editorRevision;
}
if (!next) return;
pendingSelection.current = undefined;
if (shouldFocus) root.focus({ preventScroll: true });
pendingSelectionFocus.current = false;
restoreSelection(root, next);
}, [editorRevision, plainText, runsRenderKey, value]);
const applySelectionChange = (change: Partial<Omit<RichTextSegment, 'text'>>, restoreEditor = true) => {
const range = clampSelection(selectionRef.current, totalCharacters);
if (range.start === range.end) return;
if (restoreEditor) pendingSelection.current = range;
if (!commit(updateTextRange(segments, range, change), { normalize: true })) {
pendingSelection.current = range;
setEditorRevision((revision) => revision + 1);
}
};
const replaceCurrentText = (replacement: string, rangeOverride?: TextSelection,
baseSegments = latestSegments.current): boolean => {
const baseLength = documentText(baseSegments).length;
const range = clampSelection(rangeOverride ?? selectionRef.current, baseLength);
const caret = { start: range.start + replacement.length, end: range.start + replacement.length };
selectionRef.current = caret;
setSelectionState(caret);
pendingSelection.current = caret;
pendingSelectionFocus.current = globalThis.document.activeElement === editorRef.current;
const committed = commit(replaceTextRange(baseSegments, range, replacement), { normalize: true });
if (!committed) {
selectionRef.current = range;
setSelectionState(range);
pendingSelection.current = range;
setEditorRevision((revision) => revision + 1);
}
return committed;
};
const replaceCurrentRuns = (replacements: RichTextSegment[], rangeOverride?: TextSelection,
baseSegments = latestSegments.current): boolean => {
const baseLength = documentText(baseSegments).length;
const range = clampSelection(rangeOverride ?? selectionRef.current, baseLength);
const replacementLength = documentText(replacements).length;
const caret = { start: range.start + replacementLength, end: range.start + replacementLength };
selectionRef.current = caret;
setSelectionState(caret);
pendingSelection.current = caret;
pendingSelectionFocus.current = globalThis.document.activeElement === editorRef.current;
const committed = commit(replaceRunsRange(baseSegments, range, replacements), { normalize: true });
if (!committed) {
selectionRef.current = range;
setSelectionState(range);
pendingSelection.current = range;
setEditorRevision((revision) => revision + 1);
}
return committed;
};
const repairEditorDom = (selection: TextSelection, root: HTMLElement) => {
const next = clampSelection(selection, latestText.current.length);
selectionRef.current = next;
setSelectionState(next);
pendingSelection.current = next;
pendingSelectionFocus.current = globalThis.document.activeElement === root;
setEditorRevision((revision) => revision + 1);
};
const clearNativeEventChainGuard = () => {
const guard = nativeEventChainGuard.current;
if (guard) globalThis.clearTimeout(guard.timer);
nativeEventChainGuard.current = undefined;
};
const armNativeEventChainGuard = (inputTypes: readonly string[]) => {
clearNativeEventChainGuard();
let guard: NativeEventChainGuard;
const timer = globalThis.setTimeout(() => {
if (nativeEventChainGuard.current === guard) nativeEventChainGuard.current = undefined;
}, 0);
guard = { inputTypes, timer };
nativeEventChainGuard.current = guard;
};
const commitTextSnapshot = (root: HTMLDivElement, baseSegments: RichTextSegment[], baseText: string,
nextText: string, requestedSelection: TextSelection): boolean => {
const difference = changedTextRange(baseText, nextText);
const browserSelection = clampSelection(requestedSelection, nextText.length);
selectionRef.current = browserSelection;
setSelectionState(browserSelection);
if (nextText === baseText) return false;
pendingSelection.current = browserSelection;
pendingSelectionFocus.current = globalThis.document.activeElement === root;
if (commit(replaceTextRange(baseSegments, difference.selection, difference.replacement), { normalize: true })) {
return true;
}
repairEditorDom(difference.selection, root);
return false;
};
const commitDomText = (root: HTMLDivElement, baseSegments: RichTextSegment[], baseText: string): boolean => {
const nextText = (root.textContent ?? '').replace(/\r\n?/g, '\n');
const difference = changedTextRange(baseText, nextText);
const browserSelection = selectionOffsets(root) ?? {
start: difference.selection.start + difference.replacement.length,
end: difference.selection.start + difference.replacement.length,
};
return commitTextSnapshot(root, baseSegments, baseText, nextText, browserSelection);
};
const finalizePendingComposition = (root: HTMLDivElement, observed?: CompositionTailSnapshot) => {
const pending = pendingComposition.current;
if (!pending) return;
globalThis.clearTimeout(pending.timer);
pendingComposition.current = undefined;
if (deferredExternalValue.current) {
repairEditorDom(selectionRef.current, root);
return;
}
let nextText = observed?.text ?? pending.endText;
let nextSelection = observed?.selection ?? pending.endSelection;
if (pending.data && pending.endSelection.start === pending.endSelection.end) {
const offset = pending.endSelection.start;
const duplicated = `${pending.endText.slice(0, offset)}${pending.data}${pending.endText.slice(offset)}`;
if (nextText === duplicated) {
nextText = pending.endText;
nextSelection = pending.endSelection;
}
}
const committed = commitTextSnapshot(root, pending.baseSegments, pending.baseText, nextText, nextSelection);
if (!committed && nextText === pending.baseText) repairEditorDom(nextSelection, root);
};
const handleInput = (event: FormEvent<HTMLDivElement>) => {
if (ignoreCancelledCompositionInput.current) {
repairEditorDom(selectionRef.current, event.currentTarget);
return;
}
if (composing.current || (event.nativeEvent as globalThis.InputEvent).isComposing) return;
const nextText = (event.currentTarget.textContent ?? '').replace(/\r\n?/g, '\n');
if (pendingComposition.current) {
const difference = changedTextRange(pendingComposition.current.baseText, nextText);
finalizePendingComposition(event.currentTarget, {
text: nextText,
selection: selectionOffsets(event.currentTarget) ?? {
start: difference.selection.start + difference.replacement.length,
end: difference.selection.start + difference.replacement.length,
},
});
return;
}
commitDomText(event.currentTarget, latestSegments.current, latestText.current);
};
const handleBeforeInput = (nativeEvent: globalThis.InputEvent) => {
if (ignoreCancelledCompositionInput.current) {
nativeEvent.preventDefault();
return;
}
if (composing.current || nativeEvent.isComposing) return;
if (!nativeEvent.inputType) return;
if (pendingComposition.current) return;
const guard = nativeEventChainGuard.current;
if (guard?.inputTypes.includes(nativeEvent.inputType)) {
clearNativeEventChainGuard();
nativeEvent.preventDefault();
return;
}
if (nativeEvent.inputType !== 'insertParagraph' && nativeEvent.inputType !== 'insertLineBreak') return;
nativeEvent.preventDefault();
const root = editorRef.current;
if (!root) return;
const liveSelection = selectionOffsets(root) ?? selectionRef.current;
replaceCurrentText('\n', liveSelection);
};
beforeInputHandler.current = handleBeforeInput;
useEffect(() => {
const root = editorRef.current;
if (!root) return undefined;
const listener = (event: globalThis.InputEvent) => beforeInputHandler.current(event);
root.addEventListener('beforeinput', listener);
return () => {
root.removeEventListener('beforeinput', listener);
if (nativeEventChainGuard.current) globalThis.clearTimeout(nativeEventChainGuard.current.timer);
if (pendingComposition.current) globalThis.clearTimeout(pendingComposition.current.timer);
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
};
}, []);
const handleCompositionStart = (event: CompositionEvent<HTMLDivElement>) => {
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
clearNativeEventChainGuard();
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
ignoreCompositionTimer.current = undefined;
ignoreNextCompositionEnd.current = false;
ignoreCancelledCompositionInput.current = false;
const liveSelection = selectionOffsets(event.currentTarget);
if (liveSelection) selectionRef.current = liveSelection;
compositionBase.current = { segments: latestSegments.current, text: latestText.current };
pendingSelection.current = undefined;
pendingSelectionFocus.current = false;
composing.current = true;
};
const handleCompositionUpdate = () => {
// Deliberately leave the browser-owned composition DOM untouched. Updating React
// state here would reconcile the old text and cancel Chinese/Japanese/Korean IMEs.
if (!ignoreNextCompositionEnd.current) composing.current = true;
};
const handleCompositionEnd = (event: CompositionEvent<HTMLDivElement>) => {
if (ignoreNextCompositionEnd.current) {
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
ignoreCompositionTimer.current = undefined;
ignoreNextCompositionEnd.current = false;
ignoreCancelledCompositionInput.current = false;
composing.current = false;
compositionBase.current = undefined;
repairEditorDom(selectionRef.current, event.currentTarget);
return;
}
const base = compositionBase.current ?? { segments: latestSegments.current, text: latestText.current };
compositionBase.current = undefined;
composing.current = false;
if (pendingComposition.current) globalThis.clearTimeout(pendingComposition.current.timer);
const endText = (event.currentTarget.textContent ?? '').replace(/\r\n?/g, '\n');
const difference = changedTextRange(base.text, endText);
const endSelection = selectionOffsets(event.currentTarget) ?? {
start: difference.selection.start + difference.replacement.length,
end: difference.selection.start + difference.replacement.length,
};
let snapshot: PendingComposition;
const timer = globalThis.setTimeout(() => {
if (pendingComposition.current === snapshot && editorRef.current) {
finalizePendingComposition(editorRef.current);
}
}, 0);
snapshot = {
baseSegments: base.segments, baseText: base.text, data: event.data,
endSelection, endText, timer,
};
pendingComposition.current = snapshot;
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
if (composing.current) return;
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
event.preventDefault();
clearNativeEventChainGuard();
const liveSelection = selectionOffsets(event.currentTarget);
if (liveSelection) {
selectionRef.current = liveSelection;
setSelectionState(liveSelection);
}
const plain = event.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n');
let richRuns: RichTextSegment[] | undefined;
try {
richRuns = resolveRichTextClipboardFragment(event.clipboardData.getData(RICH_TEXT_CLIPBOARD_MIME), plain);
} catch {
// Some browser/OS clipboard bridges reject unknown MIME reads. Plain text remains safe.
}
const committed = richRuns
? replaceCurrentRuns(richRuns, liveSelection)
: replaceCurrentText(plain, liveSelection);
if (!committed) clearNativeEventChainGuard();
};
const handleCopy = (event: ClipboardEvent<HTMLDivElement>) => {
if (composing.current) return;
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
const liveSelection = selectionOffsets(event.currentTarget);
if (!liveSelection || liveSelection.start === liveSelection.end) return;
const selected = normalizeRuns(sliceRuns(latestSegments.current, liveSelection.start, liveSelection.end));
const plain = documentText(selected);
if (!plain) return;
event.preventDefault();
event.clipboardData.setData('text/plain', plain);
try {
const envelope = registerRichTextClipboardFragment(selected, plain);
if (envelope) event.clipboardData.setData(RICH_TEXT_CLIPBOARD_MIME, envelope);
} catch {
// The plain-text representation still works on clipboard implementations that
// disallow custom MIME types.
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const nativeEvent = event.nativeEvent;
if (composing.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) return;
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
clearNativeEventChainGuard();
const shortcut = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (shortcut && ['b', 'i', 'u'].includes(key)) {
event.preventDefault();
rememberSelection();
if (key === 'b') applySelectionChange({ bold: !selectedStyle.bold });
if (key === 'i') applySelectionChange({ italic: !selectedStyle.italic });
if (key === 'u') applySelectionChange({ underlined: !selectedStyle.underlined });
return;
}
if (event.key === 'Enter' && !shortcut) {
event.preventDefault();
const liveSelection = editorRef.current ? selectionOffsets(editorRef.current) : undefined;
if (replaceCurrentText('\n', liveSelection)) {
armNativeEventChainGuard(['insertParagraph', 'insertLineBreak']);
}
}
};
const updateSender = (change: Partial<RichTextSender>) => {
if (!messageSender) return;
commit(segments, { sender: { ...messageSender, ...change } });
};
const keepEditorSelection = (event: MouseEvent<HTMLElement>) => event.preventDefault();
const closeVariableBrowser = () => {
setVariableBrowserOpen(false);
globalThis.requestAnimationFrame(() => variableBrowserButtonRef.current?.focus());
};
const insertVariable = (item: RichTextVariableDefinition) => {
if (!replaceCurrentText(variableInsertionExpression(item))) return;
pendingSelectionFocus.current = true;
setVariableBrowserOpen(false);
const restoreEditor = () => {
const root = editorRef.current;
if (!root) return;
root.focus({ preventScroll: true });
restoreSelection(root, selectionRef.current);
};
if (typeof globalThis.requestAnimationFrame === 'function') globalThis.requestAnimationFrame(restoreEditor);
else globalThis.setTimeout(restoreEditor, 0);
};
return <div className={`rich-text-editor${compact ? ' rich-text-editor--compact' : ''}`}>
<div className="rich-text-preview rich-text-composer" aria-label="Minecraft rich text preview">
{previewSender.visible && <span className="rich-text-preview__prefix" contentEditable={false} style={{
color: previewSender.color, fontWeight: previewSender.bold ? 700 : undefined,
fontStyle: previewSender.italic ? 'italic' : undefined, textDecoration: textDecoration(previewSender),
}}>[{previewSender.text || '…'}]{' '}</span>}
<div ref={editorRef} className="rich-text-canvas" role="textbox"
aria-label="富文本内容 / Rich text content" aria-multiline="true" contentEditable
suppressContentEditableWarning spellCheck onInput={handleInput}
onCompositionStart={handleCompositionStart} onCompositionUpdate={handleCompositionUpdate}
onCompositionEnd={handleCompositionEnd} onCopy={handleCopy} onPaste={handlePaste}
onDrop={(event) => event.preventDefault()} onKeyDown={handleKeyDown}
onKeyUp={rememberSelection} onMouseUp={rememberSelection} onFocus={rememberSelection}
data-placeholder="输入消息并框选文字设置样式 / Type a message, then select text to style it" />
</div>
<div className="rich-text-selection-toolbar" aria-label="所选文本工具栏 / Selected text toolbar">
<div className="rich-text-toolbar">
<label className="color-control"><span>所选颜色 / Selection color</span><input type="color"
aria-label="所选文字颜色 / Selected text color" disabled={!hasSelection}
value={selectedColor ?? DEFAULT_SEGMENT.color}
onChange={(event) => applySelectionChange({ color: event.target.value })} /></label>
<StyleButtons value={selectedStyle} update={(change) => applySelectionChange(change)} includeObfuscated
disabled={!hasSelection} onMouseDown={keepEditorSelection} />
</div>
<span className="field__hint" aria-live="polite">{hasSelection
? `已选择 ${currentSelection.end - currentSelection.start} 个字符 / ${currentSelection.end - currentSelection.start} characters selected`
: '请先在上方框选文字 / Select text above to apply formatting'}</span>
</div>
<div className="variable-picker-launcher">
<button ref={variableBrowserButtonRef} type="button" className="variable-picker-launcher__button"
aria-haspopup="dialog" aria-expanded={variableBrowserOpen} onMouseDown={keepEditorSelection}
onClick={() => setVariableBrowserOpen(true)}>
<span><Braces size={14} />插入变量 / Insert variable</span>
<small>可搜索 {availableVariables.length} 项 / Search {availableVariables.length}</small>
</button>
<span className="field__hint">点击浏览变量名称、代码、说明和适用范围 / Browse names, codes, descriptions, and availability</span>
</div>
{variableBrowserOpen && <VariableCatalogDialog variables={availableVariables}
selectionHint="点击任意条目插入到原选区 / Select a variable to insert it at the saved selection"
onClose={closeVariableBrowser} onSelect={insertVariable} />}
<div className="rich-text-selection-details">
<div className="form-grid">
<Field label={<><Link2 size={12} /> 所选文字点击动作 / Selected text click</>}><Select
aria-label="所选文字点击动作 / Selected text click action" disabled={!hasSelection}
value={selectedClickAction ?? '__mixed'}
onChange={(event) => {
const action = event.target.value as RichClickAction;
applySelectionChange({ clickAction: action }, false);
}}>
<option value="__mixed" disabled>多种动作 / Mixed actions</option>
<option value="none">无 / None</option><option value="open_url">打开网页 / Open URL</option>
<option value="run_command">运行玩家命令 / Run command</option>
<option value="suggest_command">填入命令 / Suggest command</option>
<option value="copy_to_clipboard">复制到剪贴板 / Copy to clipboard</option>
</Select></Field>
{selectedClickAction !== 'none' && <Field label="所选动作内容 / Selected action value"
hint={selectedClickSample ? clickValueError(selectedClickSample) : undefined}><Input
aria-label="所选动作内容 / Selected action value" disabled={!hasSelection}
type={selectedClickAction === 'open_url' ? 'url' : 'text'}
pattern={selectedClickAction === 'open_url' ? 'https?://.+'
: selectedClickAction && ['run_command', 'suggest_command'].includes(selectedClickAction) ? '/.*' : undefined}
aria-invalid={Boolean(selectedClickSample && clickValueError(selectedClickSample))}
maxLength={1024} value={selectedClickValue ?? ''}
onChange={(event) => applySelectionChange({ clickValue: event.target.value }, false)}
placeholder={selectedClickAction === undefined ? '多种内容 / Mixed values'
: selectedClickAction === 'open_url' ? 'https://example.com'
: selectedClickAction === 'copy_to_clipboard' ? '留空时复制所选文字 / Empty copies selected text'
: '/help'} /></Field>}
</div>
<div className="form-grid">
<Field label="所选文字悬停说明 / Selected text hover details"><Textarea rows={2} maxLength={1024}
aria-label="所选文字悬停说明 / Selected text hover details" disabled={!hasSelection}
value={selectedHoverText ?? ''} placeholder={selectedHoverText === undefined && hasSelection ? '多种内容 / Mixed values' : ''}
onChange={(event) => applySelectionChange({ hoverText: event.target.value }, false)} /></Field>
<Field label="所选文字 Shift+点击插入内容 / Selected text insertion"><Input maxLength={1024}
aria-label="所选文字插入内容 / Selected text insertion" disabled={!hasSelection}
value={selectedInsertion ?? ''} placeholder={selectedInsertion === undefined && hasSelection ? '多种内容 / Mixed values' : ''}
onChange={(event) => applySelectionChange({ insertion: event.target.value }, false)} /></Field>
</div>
</div>
<div className="field__hint" aria-live="polite">文本字符 / Text characters: {totalCharacters} / {MAX_RICH_TEXT_CHARACTERS}
{' · '}样式段 / Style runs: {segments.length} / {MAX_RICH_TEXT_SEGMENTS}</div>
{(limitError || validationErrors.length > 0) && <div className="form-error" role="alert">
{limitError || validationErrors.join(';')}
</div>}
<details className="rich-text-sender">
<summary><span><Braces size={14} />数值显示 / Number display</span>
<small>{numberPrecision} 位小数 / fraction digits</small></summary>
<div className="rich-text-sender__body">
<Field label="浮点变量保留位数 / Floating-point fraction digits"
hint="默认保留 2 位;只影响消息显示,不会改变传送坐标、金额或指令参数。 / Default: 2. Display only.">
<Select aria-label="浮点变量保留位数 / Floating-point fraction digits" value={numberPrecision}
onChange={(event) => commit(segments, { numberPrecision: Number(event.target.value) })}>
{Array.from({ length: MAX_MESSAGE_NUMBER_PRECISION + 1 }, (_, digits) => <option key={digits} value={digits}>
{digits}</option>)}
</Select>
</Field>
</div>
</details>
<details className="rich-text-sender" open={senderOpen} onToggle={(event) => setSenderOpen(event.currentTarget.open)}>
<summary><span><Braces size={14} />发送者 / Sender</span><small>{inheritsDefaultSender
? `全局默认 / Global default [${previewSender.text}]`
: messageSender.visible ? `[${previewSender.text}]` : '隐藏 / Hidden'}</small></summary>
<div className="rich-text-sender__body">
<label className="toggle-row"><input type="checkbox" aria-label="使用全局默认发送者 / Use global default sender"
checked={inheritsDefaultSender} onChange={(event) => commit(segments, { sender: event.target.checked ? undefined : previewSender })} />
<span><strong>使用全局默认发送者 / Use global default sender</strong><small>[{effectiveDefaultSender}]</small></span></label>
{!inheritsDefaultSender && <>
<label className="toggle-row"><input type="checkbox" checked={messageSender.visible}
onChange={(event) => updateSender({ visible: event.target.checked })} /><span><strong>{messageSender.visible ? '显示发送者' : '隐藏发送者'}</strong><small>控制消息前的 [名称] 前缀</small></span>{messageSender.visible ? <Eye size={15} /> : <EyeOff size={15} />}</label>
{messageSender.visible && <><div className="form-grid"><Field label="发送者名称 / Sender name"><Input maxLength={64}
value={messageSender.text} onChange={(event) => updateSender({ text: event.target.value })} /></Field>
<label className="color-control color-control--field"><span>发送者颜色 / Sender color</span><input type="color"
value={messageSender.color} onChange={(event) => updateSender({ color: event.target.value })} /></label></div>
<div className="rich-text-toolbar"><StyleButtons value={messageSender} update={updateSender} includeObfuscated={false} /></div></>}
</>}
</div>
</details>
</div>;
}
function StyleButtons<T extends { bold: boolean; italic: boolean; underlined: boolean; strikethrough: boolean; obfuscated?: boolean }>({
value, update, includeObfuscated, disabled = false, onMouseDown,
}: {
value: T;
update: (change: Partial<T>) => void;
includeObfuscated: boolean;
disabled?: boolean;
onMouseDown?: (event: MouseEvent<HTMLElement>) => void;
}) {
return <>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.bold ? 'is-active' : ''} aria-pressed={value.bold}
onClick={() => update({ bold: !value.bold } as Partial<T>)} title="粗体 / Bold"><Bold size={15} /></button>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.italic ? 'is-active' : ''} aria-pressed={value.italic}
onClick={() => update({ italic: !value.italic } as Partial<T>)} title="斜体 / Italic"><Italic size={15} /></button>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.underlined ? 'is-active' : ''} aria-pressed={value.underlined}
onClick={() => update({ underlined: !value.underlined } as Partial<T>)} title="下划线 / Underline"><Underline size={15} /></button>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.strikethrough ? 'is-active' : ''} aria-pressed={value.strikethrough}
onClick={() => update({ strikethrough: !value.strikethrough } as Partial<T>)} title="删除线 / Strikethrough"><Strikethrough size={15} /></button>
{includeObfuscated && <button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.obfuscated ? 'is-active' : ''} aria-pressed={value.obfuscated}
onClick={() => update({ obfuscated: !value.obfuscated } as Partial<T>)} title="随机字符 / Obfuscated"><Sparkles size={15} /></button>}
</>;
}
import { Bold, Braces, Eye, EyeOff, Italic, Link2, Sparkles,
Strikethrough, Underline } from 'lucide-react';
import {
useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type CompositionEvent,
type FormEvent, type KeyboardEvent, type MouseEvent,
} from 'react';
import type { RichTextVariableDefinition } from './rich-text-variables';
import {
mergeRichTextVariableCatalog,
VariableCatalogDialog,
variableInsertionExpression,
} from './variable-catalog-picker';
import { Field, Input, Select, Textarea } from './ui';
export { RICH_TEXT_VARIABLE_CATALOG } from './rich-text-variables';
export type { RichTextVariableDefinition } from './rich-text-variables';
export { mergeRichTextVariableCatalog, richTextVariableExpression } from './variable-catalog-picker';
export const RICH_TEXT_PREFIX = '@xfesm-rich:v1:';
export const MAX_RICH_TEXT_ENCODED_CHARACTERS = 65_536;
export const MAX_RICH_TEXT_CHARACTERS = 8192;
export const MAX_RICH_TEXT_SEGMENT_CHARACTERS = 2048;
export const MAX_RICH_TEXT_SEGMENTS = 256;
export const DEFAULT_MESSAGE_SENDER = 'XFEServerManager';
export const DEFAULT_MESSAGE_NUMBER_PRECISION = 2;
export const MAX_MESSAGE_NUMBER_PRECISION = 10;
export const RICH_TEXT_CLIPBOARD_MIME = 'application/x-xfeservermanager-rich-text+json';
const RICH_TEXT_CLIPBOARD_FRAGMENT_LIMIT = MAX_RICH_TEXT_SEGMENTS;
const RICH_TEXT_CLIPBOARD_FRAGMENT_TTL_MS = 10 * 60 * 1000;
const RICH_TEXT_CLIPBOARD_ENVELOPE_CHARACTERS = 128;
export type RichClickAction = 'none' | 'open_url' | 'run_command' | 'suggest_command' | 'copy_to_clipboard';
export interface RichTextSegment {
text: string;
color: string;
bold: boolean;
italic: boolean;
underlined: boolean;
strikethrough: boolean;
obfuscated: boolean;
insertion: string;
clickAction: RichClickAction;
clickValue: string;
hoverText: string;
}
export interface RichTextSender {
visible: boolean;
text: string;
color: string;
bold: boolean;
italic: boolean;
underlined: boolean;
strikethrough: boolean;
}
export interface RichTextDocument {
version: 1;
/** Fraction digits used for floating-point variables in player-facing text. */
numberPrecision: number;
/** Missing means that the server-wide default sender should be used. */
sender?: RichTextSender;
segments: RichTextSegment[];
}
const DEFAULT_SEGMENT: RichTextSegment = {
text: '', color: '#ffffff', bold: false, italic: false, underlined: false,
strikethrough: false, obfuscated: false, insertion: '', clickAction: 'none', clickValue: '', hoverText: '',
};
const DEFAULT_SENDER: RichTextSender = {
visible: true, text: DEFAULT_MESSAGE_SENDER, color: '#71e6a1', bold: false,
italic: false, underlined: false, strikethrough: false,
};
function segment(value?: Partial<RichTextSegment>): RichTextSegment {
return { ...DEFAULT_SEGMENT, ...value };
}
function sender(value?: Partial<RichTextSender>): RichTextSender {
return { ...DEFAULT_SENDER, ...value };
}
function safeColor(value: unknown, fallback: string): string {
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : fallback;
}
export function decodeRichTextDocument(value: string): RichTextDocument {
if (!value.startsWith(RICH_TEXT_PREFIX)) {
return { version: 1, numberPrecision: DEFAULT_MESSAGE_NUMBER_PRECISION,
segments: [segment({ text: value })] };
}
try {
const parsed = JSON.parse(value.slice(RICH_TEXT_PREFIX.length)) as Partial<RichTextDocument>;
const parsedSender = parsed.sender;
const segments = Array.isArray(parsed.segments) && parsed.segments.length > 0
? parsed.segments.slice(0, MAX_RICH_TEXT_SEGMENTS).map((item) => segment({
text: typeof item.text === 'string' ? item.text : '',
color: safeColor(item.color, DEFAULT_SEGMENT.color),
bold: item.bold === true,
italic: item.italic === true,
underlined: item.underlined === true,
strikethrough: item.strikethrough === true,
obfuscated: item.obfuscated === true,
insertion: typeof item.insertion === 'string' ? item.insertion : '',
clickAction: ['open_url', 'run_command', 'suggest_command', 'copy_to_clipboard'].includes(item.clickAction ?? '')
? item.clickAction as RichClickAction : 'none',
clickValue: typeof item.clickValue === 'string' ? item.clickValue : '',
hoverText: typeof item.hoverText === 'string' ? item.hoverText : '',
})) : [segment()];
return {
version: 1,
numberPrecision: Number.isInteger(parsed.numberPrecision)
&& (parsed.numberPrecision ?? -1) >= 0 && (parsed.numberPrecision ?? 11) <= MAX_MESSAGE_NUMBER_PRECISION
? parsed.numberPrecision as number : DEFAULT_MESSAGE_NUMBER_PRECISION,
...(parsedSender && typeof parsedSender === 'object' ? { sender: sender({
visible: parsedSender.visible !== false,
text: typeof parsedSender.text === 'string' ? parsedSender.text : DEFAULT_SENDER.text,
color: safeColor(parsedSender.color, DEFAULT_SENDER.color),
bold: parsedSender.bold === true,
italic: parsedSender.italic === true,
underlined: parsedSender.underlined === true,
strikethrough: parsedSender.strikethrough === true,
}) } : {}),
segments,
};
} catch {
return { version: 1, numberPrecision: DEFAULT_MESSAGE_NUMBER_PRECISION,
segments: [segment({ text: value })] };
}
}
export function decodeRichText(value: string): RichTextSegment[] {
return decodeRichTextDocument(value).segments;
}
export function encodeRichText(segments: RichTextSegment[], messageSender?: RichTextSender,
numberPrecision = DEFAULT_MESSAGE_NUMBER_PRECISION): string {
const document: RichTextDocument = {
version: 1,
numberPrecision: Math.max(0, Math.min(MAX_MESSAGE_NUMBER_PRECISION, Math.trunc(numberPrecision))),
...(messageSender ? { sender: sender(messageSender) } : {}),
segments: segments.slice(0, MAX_RICH_TEXT_SEGMENTS).map((item) => segment(item)),
};
return RICH_TEXT_PREFIX + JSON.stringify(document);
}
export function richTextPlainText(value: string): string {
return decodeRichText(value).map((item) => item.text).join('');
}
function clickValueError(item: RichTextSegment): string | undefined {
if (item.clickAction === 'open_url') {
try {
const target = new URL(item.clickValue);
if ((target.protocol === 'http:' || target.protocol === 'https:') && target.hostname) return undefined;
} catch {
// Fall through to the actionable editor error below.
}
return '网页地址必须是完整的 http:// 或 https:// URL / URL must use http:// or https://';
}
if ((item.clickAction === 'run_command' || item.clickAction === 'suggest_command')
&& !item.clickValue.startsWith('/')) {
return '命令点击内容必须以 / 开头 / Command click value must start with /';
}
return undefined;
}
export function richTextValidationErrors(value: string): string[] {
const document = decodeRichTextDocument(value);
const errors = document.segments.map(clickValueError).filter((error): error is string => Boolean(error));
if (value.length > MAX_RICH_TEXT_ENCODED_CHARACTERS) {
errors.unshift(`富文本编码不能超过 ${MAX_RICH_TEXT_ENCODED_CHARACTERS} 个字符 / Encoded rich text cannot exceed ${MAX_RICH_TEXT_ENCODED_CHARACTERS} characters`);
}
if (document.segments.some((item) => item.text.length > MAX_RICH_TEXT_SEGMENT_CHARACTERS)) {
errors.unshift(`单个文本段不能超过 ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} 个字符 / A segment cannot exceed ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} characters`);
}
const totalCharacters = document.segments.reduce((total, item) => total + item.text.length, 0);
if (totalCharacters > MAX_RICH_TEXT_CHARACTERS) {
errors.unshift(`文本总长度不能超过 ${MAX_RICH_TEXT_CHARACTERS} 个字符 / Total text cannot exceed ${MAX_RICH_TEXT_CHARACTERS} characters`);
}
return errors;
}
export function richTextIsValid(value: string): boolean {
return richTextValidationErrors(value).length === 0;
}
function textDecoration(value: { underlined: boolean; strikethrough: boolean }): string | undefined {
return [value.underlined && 'underline', value.strikethrough && 'line-through'].filter(Boolean).join(' ') || undefined;
}
interface TextSelection { start: number; end: number }
interface NativeEventChainGuard {
inputTypes: readonly string[];
timer: ReturnType<typeof globalThis.setTimeout>;
}
interface PendingComposition {
baseSegments: RichTextSegment[];
baseText: string;
data: string;
endSelection: TextSelection;
endText: string;
timer: ReturnType<typeof globalThis.setTimeout>;
}
interface RichTextClipboardFragment {
createdAt: number;
plain: string;
runs: RichTextSegment[];
}
const richTextClipboardFragments = new Map<string, RichTextClipboardFragment>();
interface RichTextClipboardEnvelope {
version: 1;
id: string;
}
interface CompositionBase {
segments: RichTextSegment[];
text: string;
}
interface DeferredExternalValue {
value: string;
}
interface CompositionTailSnapshot {
selection: TextSelection;
text: string;
}
function segmentAttributesEqual(left: RichTextSegment, right: RichTextSegment): boolean {
return left.color === right.color && left.bold === right.bold && left.italic === right.italic
&& left.underlined === right.underlined && left.strikethrough === right.strikethrough
&& left.obfuscated === right.obfuscated && left.insertion === right.insertion
&& left.clickAction === right.clickAction && left.clickValue === right.clickValue
&& left.hoverText === right.hoverText;
}
function splitSegmentText(item: RichTextSegment): RichTextSegment[] {
if (!item.text) return [];
const result: RichTextSegment[] = [];
let offset = 0;
while (offset < item.text.length) {
let end = Math.min(offset + MAX_RICH_TEXT_SEGMENT_CHARACTERS, item.text.length);
if (end < item.text.length && /[\uD800-\uDBFF]/.test(item.text[end - 1])
&& /[\uDC00-\uDFFF]/.test(item.text[end])) end -= 1;
result.push({ ...item, text: item.text.slice(offset, end) });
offset = end;
}
return result;
}
function normalizeRuns(items: RichTextSegment[]): RichTextSegment[] {
const populated = items.flatMap(splitSegmentText);
if (!populated.length) return [segment(items[0])];
return populated.reduce<RichTextSegment[]>((result, item) => {
const previous = result.at(-1);
if (previous && segmentAttributesEqual(previous, item)
&& previous.text.length + item.text.length <= MAX_RICH_TEXT_SEGMENT_CHARACTERS) {
previous.text += item.text;
} else {
result.push({ ...item });
}
return result;
}, []);
}
function documentText(segments: RichTextSegment[]): string {
return segments.map((item) => item.text).join('');
}
function clampSelection(value: TextSelection, length: number): TextSelection {
const start = Math.max(0, Math.min(value.start, value.end, length));
const end = Math.max(start, Math.min(Math.max(value.start, value.end), length));
return { start, end };
}
function sliceRuns(items: RichTextSegment[], from: number, to: number): RichTextSegment[] {
const result: RichTextSegment[] = [];
let offset = 0;
items.forEach((item) => {
const itemStart = offset;
const itemEnd = offset + item.text.length;
offset = itemEnd;
const start = Math.max(from, itemStart);
const end = Math.min(to, itemEnd);
if (start < end) result.push({ ...item, text: item.text.slice(start - itemStart, end - itemStart) });
});
return result;
}
function runAtOffset(items: RichTextSegment[], requestedOffset: number): RichTextSegment {
const total = documentText(items).length;
const target = Math.max(0, Math.min(requestedOffset, total));
let offset = 0;
let previous: RichTextSegment | undefined;
for (const item of items) {
if (!item.text) continue;
if (target === offset) return previous ?? item;
if (target < offset + item.text.length) return item;
offset += item.text.length;
previous = item;
}
return previous ?? items[0] ?? segment();
}
function replaceTextRange(items: RichTextSegment[], selection: TextSelection, replacement: string): RichTextSegment[] {
const total = documentText(items).length;
const range = clampSelection(selection, total);
const template = runAtOffset(items, range.start);
const next = [
...sliceRuns(items, 0, range.start),
...(replacement ? [{ ...template, text: replacement }] : []),
...sliceRuns(items, range.end, total),
];
return normalizeRuns(next.length ? next : [{ ...template, text: '' }]);
}
function replaceRunsRange(items: RichTextSegment[], selection: TextSelection,
replacements: RichTextSegment[]): RichTextSegment[] {
const total = documentText(items).length;
const range = clampSelection(selection, total);
const template = runAtOffset(items, range.start);
const next = [
...sliceRuns(items, 0, range.start),
...replacements.map((item) => ({ ...item })),
...sliceRuns(items, range.end, total),
];
return normalizeRuns(next.length ? next : [{ ...template, text: '' }]);
}
function updateTextRange(items: RichTextSegment[], selection: TextSelection,
change: Partial<Omit<RichTextSegment, 'text'>>): RichTextSegment[] {
const total = documentText(items).length;
const range = clampSelection(selection, total);
if (range.start === range.end) return items;
const result: RichTextSegment[] = [];
let offset = 0;
items.forEach((item) => {
const itemStart = offset;
const itemEnd = offset + item.text.length;
offset = itemEnd;
if (!item.text || itemEnd <= range.start || itemStart >= range.end) {
result.push(item);
return;
}
const selectedStart = Math.max(range.start, itemStart) - itemStart;
const selectedEnd = Math.min(range.end, itemEnd) - itemStart;
if (selectedStart > 0) result.push({ ...item, text: item.text.slice(0, selectedStart) });
result.push({ ...item, ...change, text: item.text.slice(selectedStart, selectedEnd) });
if (selectedEnd < item.text.length) result.push({ ...item, text: item.text.slice(selectedEnd) });
});
return normalizeRuns(result);
}
function runsInRange(items: RichTextSegment[], selection: TextSelection): RichTextSegment[] {
const range = clampSelection(selection, documentText(items).length);
let offset = 0;
return items.filter((item) => {
const itemStart = offset;
const itemEnd = offset + item.text.length;
offset = itemEnd;
return item.text.length > 0 && itemEnd > range.start && itemStart < range.end;
});
}
function selectionOffsets(root: HTMLElement): TextSelection | undefined {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return undefined;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return undefined;
const beforeStart = document.createRange();
beforeStart.selectNodeContents(root);
beforeStart.setEnd(range.startContainer, range.startOffset);
const beforeEnd = document.createRange();
beforeEnd.selectNodeContents(root);
beforeEnd.setEnd(range.endContainer, range.endOffset);
return clampSelection({ start: beforeStart.toString().length, end: beforeEnd.toString().length }, root.textContent?.length ?? 0);
}
function textNodeAtOffset(root: HTMLElement, requestedOffset: number): { node: Node; offset: number } {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let remaining = Math.max(0, requestedOffset);
let last: Text | undefined;
for (let current = walker.nextNode() as Text | null; current; current = walker.nextNode() as Text | null) {
last = current;
if (remaining <= current.data.length) return { node: current, offset: remaining };
remaining -= current.data.length;
}
return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 };
}
function restoreSelection(root: HTMLElement, selection: TextSelection): void {
const range = clampSelection(selection, root.textContent?.length ?? 0);
const start = textNodeAtOffset(root, range.start);
const end = textNodeAtOffset(root, range.end);
const domRange = document.createRange();
domRange.setStart(start.node, start.offset);
domRange.setEnd(end.node, end.offset);
const domSelection = window.getSelection();
domSelection?.removeAllRanges();
domSelection?.addRange(domRange);
}
function renderEditorRuns(root: HTMLElement, items: RichTextSegment[]): void {
const fragment = document.createDocumentFragment();
items.forEach((item, index) => {
if (!item.text) return;
const run = document.createElement('span');
run.dataset.richRun = String(index);
if (item.hoverText) run.title = item.hoverText;
run.style.color = item.color;
if (item.bold) run.style.fontWeight = '700';
if (item.italic) run.style.fontStyle = 'italic';
const decoration = textDecoration(item);
if (decoration) run.style.textDecoration = decoration;
if (item.obfuscated) run.style.filter = 'blur(2px)';
run.textContent = item.text;
fragment.append(run);
});
root.replaceChildren(fragment);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function copyRichTextRuns(items: RichTextSegment[]): RichTextSegment[] {
return items.map((item) => ({ ...item }));
}
function pruneRichTextClipboardFragments(now: number): void {
for (const [id, fragment] of richTextClipboardFragments) {
if (now - fragment.createdAt > RICH_TEXT_CLIPBOARD_FRAGMENT_TTL_MS) richTextClipboardFragments.delete(id);
}
while (richTextClipboardFragments.size >= RICH_TEXT_CLIPBOARD_FRAGMENT_LIMIT) {
const oldest = richTextClipboardFragments.keys().next().value as string | undefined;
if (!oldest) break;
richTextClipboardFragments.delete(oldest);
}
}
function registerRichTextClipboardFragment(items: RichTextSegment[], plain: string): string | undefined {
const cryptoApi = globalThis.crypto;
if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return undefined;
const runs = normalizeRuns(copyRichTextRuns(items));
if (documentText(runs) !== plain || !richTextIsValid(encodeRichText(runs))) return undefined;
try {
const now = Date.now();
pruneRichTextClipboardFragments(now);
for (let attempt = 0; attempt < 4; attempt += 1) {
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
const id = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
if (richTextClipboardFragments.has(id)) continue;
richTextClipboardFragments.set(id, { createdAt: now, plain, runs });
return JSON.stringify({ version: 1, id } satisfies RichTextClipboardEnvelope);
}
} catch {
// Sandboxed or legacy browsers can deny cryptographic randomness. In that case
// copy remains standards-compatible plain text and no metadata leaves the page.
}
return undefined;
}
function resolveRichTextClipboardFragment(value: string, plain: string): RichTextSegment[] | undefined {
if (!value || value.length > RICH_TEXT_CLIPBOARD_ENVELOPE_CHARACTERS) return undefined;
try {
const parsed: unknown = JSON.parse(value);
if (!isRecord(parsed) || parsed.version !== 1 || typeof parsed.id !== 'string'
|| !/^[0-9a-f]{32}$/.test(parsed.id) || Object.keys(parsed).some((key) => key !== 'version' && key !== 'id')) {
return undefined;
}
const fragment = richTextClipboardFragments.get(parsed.id);
if (!fragment || Date.now() - fragment.createdAt > RICH_TEXT_CLIPBOARD_FRAGMENT_TTL_MS
|| fragment.plain !== plain || documentText(fragment.runs) !== plain) {
if (fragment) richTextClipboardFragments.delete(parsed.id);
return undefined;
}
return copyRichTextRuns(fragment.runs);
} catch {
return undefined;
}
}
function changedTextRange(previous: string, next: string): { selection: TextSelection; replacement: string } {
let start = 0;
while (start < previous.length && start < next.length && previous[start] === next[start]) start += 1;
let previousEnd = previous.length;
let nextEnd = next.length;
while (previousEnd > start && nextEnd > start && previous[previousEnd - 1] === next[nextEnd - 1]) {
previousEnd -= 1;
nextEnd -= 1;
}
return { selection: { start, end: previousEnd }, replacement: next.slice(start, nextEnd) };
}
function commonRunValue<K extends keyof RichTextSegment>(items: RichTextSegment[], key: K): RichTextSegment[K] | undefined {
const first = items[0]?.[key];
return items.length > 0 && items.every((item) => item[key] === first) ? first : undefined;
}
export function RichTextEditor({ value, onChange, compact = false,
defaultSenderName = DEFAULT_MESSAGE_SENDER, variables = [], variableCatalog = [] }: {
value: string;
onChange: (value: string) => void;
compact?: boolean;
defaultSenderName?: string;
variables?: string[];
variableCatalog?: readonly RichTextVariableDefinition[];
}) {
const richDocument = decodeRichTextDocument(value);
const { segments, sender: messageSender, numberPrecision } = richDocument;
const plainText = documentText(segments);
const totalCharacters = plainText.length;
const [senderOpen, setSenderOpen] = useState(!compact);
const [limitError, setLimitError] = useState('');
const [variableBrowserOpen, setVariableBrowserOpen] = useState(false);
const [selectionState, setSelectionState] = useState<TextSelection>({ start: totalCharacters, end: totalCharacters });
const [editorRevision, setEditorRevision] = useState(0);
const editorRef = useRef<HTMLDivElement>(null);
const variableBrowserButtonRef = useRef<HTMLButtonElement>(null);
const selectionRef = useRef<TextSelection>({ start: totalCharacters, end: totalCharacters });
const pendingSelection = useRef<TextSelection | undefined>(undefined);
const pendingSelectionFocus = useRef(false);
const composing = useRef(false);
const compositionBase = useRef<CompositionBase | undefined>(undefined);
const pendingComposition = useRef<PendingComposition | undefined>(undefined);
const nativeEventChainGuard = useRef<NativeEventChainGuard | undefined>(undefined);
const ignoreNextCompositionEnd = useRef(false);
const ignoreCancelledCompositionInput = useRef(false);
const ignoreCompositionTimer = useRef<ReturnType<typeof globalThis.setTimeout> | undefined>(undefined);
const deferredExternalValue = useRef<DeferredExternalValue | undefined>(undefined);
const beforeInputHandler = useRef<(event: globalThis.InputEvent) => void>(() => undefined);
const latestSegments = useRef(segments);
const latestText = useRef(plainText);
const latestValue = useRef(value);
const observedPropValue = useRef(value);
const renderedRunsKey = useRef<string | undefined>(undefined);
const renderedRevision = useRef(-1);
const runsRenderKey = JSON.stringify(segments);
if (observedPropValue.current !== value) {
observedPropValue.current = value;
if (composing.current || pendingComposition.current) deferredExternalValue.current = { value };
else if (latestValue.current !== value) {
latestSegments.current = segments;
latestText.current = plainText;
latestValue.current = value;
}
}
const currentSelection = clampSelection(selectionState, totalCharacters);
const selectedRuns = runsInRange(segments, currentSelection);
const hasSelection = currentSelection.end > currentSelection.start;
const inheritsDefaultSender = messageSender === undefined;
const effectiveDefaultSender = defaultSenderName.trim() || DEFAULT_MESSAGE_SENDER;
const previewSender = messageSender
? { ...messageSender, text: messageSender.text.trim() || effectiveDefaultSender }
: sender({ text: effectiveDefaultSender });
const availableVariables = mergeRichTextVariableCatalog(variableCatalog, variables);
const validationErrors = [...new Set(richTextValidationErrors(value))];
const selectedStyle = {
bold: selectedRuns.length > 0 && selectedRuns.every((item) => item.bold),
italic: selectedRuns.length > 0 && selectedRuns.every((item) => item.italic),
underlined: selectedRuns.length > 0 && selectedRuns.every((item) => item.underlined),
strikethrough: selectedRuns.length > 0 && selectedRuns.every((item) => item.strikethrough),
obfuscated: selectedRuns.length > 0 && selectedRuns.every((item) => item.obfuscated),
};
const selectedColor = commonRunValue(selectedRuns, 'color');
const selectedClickAction = commonRunValue(selectedRuns, 'clickAction');
const selectedClickValue = commonRunValue(selectedRuns, 'clickValue');
const selectedHoverText = commonRunValue(selectedRuns, 'hoverText');
const selectedInsertion = commonRunValue(selectedRuns, 'insertion');
const selectedClickSample = selectedClickAction && selectedClickAction !== 'none'
? { ...(selectedRuns[0] ?? DEFAULT_SEGMENT), clickAction: selectedClickAction,
clickValue: selectedClickValue ?? '' }
: undefined;
const commit = (rawSegments = segments, options: {
sender?: RichTextSender; numberPrecision?: number; normalize?: boolean;
} = {}): boolean => {
const nextSegments = options.normalize ? normalizeRuns(rawSegments) : rawSegments;
const nextSender = Object.prototype.hasOwnProperty.call(options, 'sender') ? options.sender : messageSender;
const nextNumberPrecision = Object.prototype.hasOwnProperty.call(options, 'numberPrecision')
? options.numberPrecision ?? DEFAULT_MESSAGE_NUMBER_PRECISION : numberPrecision;
const nextTotal = nextSegments.reduce((total, item) => total + item.text.length, 0);
if (nextSegments.length > MAX_RICH_TEXT_SEGMENTS) {
setLimitError(`富文本样式段不能超过 ${MAX_RICH_TEXT_SEGMENTS} 个 / Rich text cannot exceed ${MAX_RICH_TEXT_SEGMENTS} style runs`);
return false;
}
if (nextSegments.some((item) => item.text.length > MAX_RICH_TEXT_SEGMENT_CHARACTERS)) {
setLimitError(`单个文本段不能超过 ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} 个字符 / A segment cannot exceed ${MAX_RICH_TEXT_SEGMENT_CHARACTERS} characters`);
return false;
}
if (nextTotal > MAX_RICH_TEXT_CHARACTERS) {
setLimitError(`文本总长度不能超过 ${MAX_RICH_TEXT_CHARACTERS} 个字符 / Total text cannot exceed ${MAX_RICH_TEXT_CHARACTERS} characters`);
return false;
}
const encoded = encodeRichText(nextSegments, nextSender, nextNumberPrecision);
if (encoded.length > MAX_RICH_TEXT_ENCODED_CHARACTERS) {
setLimitError(`富文本编码不能超过 ${MAX_RICH_TEXT_ENCODED_CHARACTERS} 个字符 / Encoded rich text cannot exceed ${MAX_RICH_TEXT_ENCODED_CHARACTERS} characters`);
return false;
}
setLimitError('');
latestSegments.current = nextSegments;
latestText.current = documentText(nextSegments);
latestValue.current = encoded;
onChange(encoded);
return true;
};
const rememberSelection = () => {
const root = editorRef.current;
if (!root) return;
const next = selectionOffsets(root);
if (!next) return;
selectionRef.current = next;
if (!composing.current) setSelectionState((current) => current.start === next.start && current.end === next.end
? current : next);
};
useEffect(() => {
const listener = () => rememberSelection();
globalThis.document.addEventListener('selectionchange', listener);
return () => globalThis.document.removeEventListener('selectionchange', listener);
});
useLayoutEffect(() => {
const root = editorRef.current;
if (!root) return;
let forceRebuild = false;
if (composing.current || pendingComposition.current) {
if (!deferredExternalValue.current) return;
composing.current = false;
compositionBase.current = undefined;
ignoreNextCompositionEnd.current = true;
ignoreCancelledCompositionInput.current = true;
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
ignoreCompositionTimer.current = globalThis.setTimeout(() => {
ignoreCancelledCompositionInput.current = false;
ignoreCompositionTimer.current = undefined;
}, 0);
if (pendingComposition.current) globalThis.clearTimeout(pendingComposition.current.timer);
pendingComposition.current = undefined;
if (nativeEventChainGuard.current) globalThis.clearTimeout(nativeEventChainGuard.current.timer);
nativeEventChainGuard.current = undefined;
deferredExternalValue.current = undefined;
latestSegments.current = segments;
latestText.current = plainText;
latestValue.current = value;
const externalSelection = clampSelection(selectionRef.current, plainText.length);
selectionRef.current = externalSelection;
setSelectionState(externalSelection);
pendingSelection.current = externalSelection;
pendingSelectionFocus.current = globalThis.document.activeElement === root;
forceRebuild = true;
}
const next = pendingSelection.current ?? selectionOffsets(root);
const shouldFocus = pendingSelectionFocus.current || globalThis.document.activeElement === root;
if (forceRebuild || renderedRunsKey.current !== runsRenderKey || renderedRevision.current !== editorRevision) {
renderEditorRuns(root, segments);
renderedRunsKey.current = runsRenderKey;
renderedRevision.current = editorRevision;
}
if (!next) return;
pendingSelection.current = undefined;
if (shouldFocus) root.focus({ preventScroll: true });
pendingSelectionFocus.current = false;
restoreSelection(root, next);
}, [editorRevision, plainText, runsRenderKey, value]);
const applySelectionChange = (change: Partial<Omit<RichTextSegment, 'text'>>, restoreEditor = true) => {
const range = clampSelection(selectionRef.current, totalCharacters);
if (range.start === range.end) return;
if (restoreEditor) pendingSelection.current = range;
if (!commit(updateTextRange(segments, range, change), { normalize: true })) {
pendingSelection.current = range;
setEditorRevision((revision) => revision + 1);
}
};
const replaceCurrentText = (replacement: string, rangeOverride?: TextSelection,
baseSegments = latestSegments.current): boolean => {
const baseLength = documentText(baseSegments).length;
const range = clampSelection(rangeOverride ?? selectionRef.current, baseLength);
const caret = { start: range.start + replacement.length, end: range.start + replacement.length };
selectionRef.current = caret;
setSelectionState(caret);
pendingSelection.current = caret;
pendingSelectionFocus.current = globalThis.document.activeElement === editorRef.current;
const committed = commit(replaceTextRange(baseSegments, range, replacement), { normalize: true });
if (!committed) {
selectionRef.current = range;
setSelectionState(range);
pendingSelection.current = range;
setEditorRevision((revision) => revision + 1);
}
return committed;
};
const replaceCurrentRuns = (replacements: RichTextSegment[], rangeOverride?: TextSelection,
baseSegments = latestSegments.current): boolean => {
const baseLength = documentText(baseSegments).length;
const range = clampSelection(rangeOverride ?? selectionRef.current, baseLength);
const replacementLength = documentText(replacements).length;
const caret = { start: range.start + replacementLength, end: range.start + replacementLength };
selectionRef.current = caret;
setSelectionState(caret);
pendingSelection.current = caret;
pendingSelectionFocus.current = globalThis.document.activeElement === editorRef.current;
const committed = commit(replaceRunsRange(baseSegments, range, replacements), { normalize: true });
if (!committed) {
selectionRef.current = range;
setSelectionState(range);
pendingSelection.current = range;
setEditorRevision((revision) => revision + 1);
}
return committed;
};
const repairEditorDom = (selection: TextSelection, root: HTMLElement) => {
const next = clampSelection(selection, latestText.current.length);
selectionRef.current = next;
setSelectionState(next);
pendingSelection.current = next;
pendingSelectionFocus.current = globalThis.document.activeElement === root;
setEditorRevision((revision) => revision + 1);
};
const clearNativeEventChainGuard = () => {
const guard = nativeEventChainGuard.current;
if (guard) globalThis.clearTimeout(guard.timer);
nativeEventChainGuard.current = undefined;
};
const armNativeEventChainGuard = (inputTypes: readonly string[]) => {
clearNativeEventChainGuard();
let guard: NativeEventChainGuard;
const timer = globalThis.setTimeout(() => {
if (nativeEventChainGuard.current === guard) nativeEventChainGuard.current = undefined;
}, 0);
guard = { inputTypes, timer };
nativeEventChainGuard.current = guard;
};
const commitTextSnapshot = (root: HTMLDivElement, baseSegments: RichTextSegment[], baseText: string,
nextText: string, requestedSelection: TextSelection): boolean => {
const difference = changedTextRange(baseText, nextText);
const browserSelection = clampSelection(requestedSelection, nextText.length);
selectionRef.current = browserSelection;
setSelectionState(browserSelection);
if (nextText === baseText) return false;
pendingSelection.current = browserSelection;
pendingSelectionFocus.current = globalThis.document.activeElement === root;
if (commit(replaceTextRange(baseSegments, difference.selection, difference.replacement), { normalize: true })) {
return true;
}
repairEditorDom(difference.selection, root);
return false;
};
const commitDomText = (root: HTMLDivElement, baseSegments: RichTextSegment[], baseText: string): boolean => {
const nextText = (root.textContent ?? '').replace(/\r\n?/g, '\n');
const difference = changedTextRange(baseText, nextText);
const browserSelection = selectionOffsets(root) ?? {
start: difference.selection.start + difference.replacement.length,
end: difference.selection.start + difference.replacement.length,
};
return commitTextSnapshot(root, baseSegments, baseText, nextText, browserSelection);
};
const finalizePendingComposition = (root: HTMLDivElement, observed?: CompositionTailSnapshot) => {
const pending = pendingComposition.current;
if (!pending) return;
globalThis.clearTimeout(pending.timer);
pendingComposition.current = undefined;
if (deferredExternalValue.current) {
repairEditorDom(selectionRef.current, root);
return;
}
let nextText = observed?.text ?? pending.endText;
let nextSelection = observed?.selection ?? pending.endSelection;
if (pending.data && pending.endSelection.start === pending.endSelection.end) {
const offset = pending.endSelection.start;
const duplicated = `${pending.endText.slice(0, offset)}${pending.data}${pending.endText.slice(offset)}`;
if (nextText === duplicated) {
nextText = pending.endText;
nextSelection = pending.endSelection;
}
}
const committed = commitTextSnapshot(root, pending.baseSegments, pending.baseText, nextText, nextSelection);
if (!committed && nextText === pending.baseText) repairEditorDom(nextSelection, root);
};
const handleInput = (event: FormEvent<HTMLDivElement>) => {
if (ignoreCancelledCompositionInput.current) {
repairEditorDom(selectionRef.current, event.currentTarget);
return;
}
if (composing.current || (event.nativeEvent as globalThis.InputEvent).isComposing) return;
const nextText = (event.currentTarget.textContent ?? '').replace(/\r\n?/g, '\n');
if (pendingComposition.current) {
const difference = changedTextRange(pendingComposition.current.baseText, nextText);
finalizePendingComposition(event.currentTarget, {
text: nextText,
selection: selectionOffsets(event.currentTarget) ?? {
start: difference.selection.start + difference.replacement.length,
end: difference.selection.start + difference.replacement.length,
},
});
return;
}
commitDomText(event.currentTarget, latestSegments.current, latestText.current);
};
const handleBeforeInput = (nativeEvent: globalThis.InputEvent) => {
if (ignoreCancelledCompositionInput.current) {
nativeEvent.preventDefault();
return;
}
if (composing.current || nativeEvent.isComposing) return;
if (!nativeEvent.inputType) return;
if (pendingComposition.current) return;
const guard = nativeEventChainGuard.current;
if (guard?.inputTypes.includes(nativeEvent.inputType)) {
clearNativeEventChainGuard();
nativeEvent.preventDefault();
return;
}
if (nativeEvent.inputType !== 'insertParagraph' && nativeEvent.inputType !== 'insertLineBreak') return;
nativeEvent.preventDefault();
const root = editorRef.current;
if (!root) return;
const liveSelection = selectionOffsets(root) ?? selectionRef.current;
replaceCurrentText('\n', liveSelection);
};
beforeInputHandler.current = handleBeforeInput;
useEffect(() => {
const root = editorRef.current;
if (!root) return undefined;
const listener = (event: globalThis.InputEvent) => beforeInputHandler.current(event);
root.addEventListener('beforeinput', listener);
return () => {
root.removeEventListener('beforeinput', listener);
if (nativeEventChainGuard.current) globalThis.clearTimeout(nativeEventChainGuard.current.timer);
if (pendingComposition.current) globalThis.clearTimeout(pendingComposition.current.timer);
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
};
}, []);
const handleCompositionStart = (event: CompositionEvent<HTMLDivElement>) => {
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
clearNativeEventChainGuard();
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
ignoreCompositionTimer.current = undefined;
ignoreNextCompositionEnd.current = false;
ignoreCancelledCompositionInput.current = false;
const liveSelection = selectionOffsets(event.currentTarget);
if (liveSelection) selectionRef.current = liveSelection;
compositionBase.current = { segments: latestSegments.current, text: latestText.current };
pendingSelection.current = undefined;
pendingSelectionFocus.current = false;
composing.current = true;
};
const handleCompositionUpdate = () => {
// Deliberately leave the browser-owned composition DOM untouched. Updating React
// state here would reconcile the old text and cancel Chinese/Japanese/Korean IMEs.
if (!ignoreNextCompositionEnd.current) composing.current = true;
};
const handleCompositionEnd = (event: CompositionEvent<HTMLDivElement>) => {
if (ignoreNextCompositionEnd.current) {
if (ignoreCompositionTimer.current !== undefined) globalThis.clearTimeout(ignoreCompositionTimer.current);
ignoreCompositionTimer.current = undefined;
ignoreNextCompositionEnd.current = false;
ignoreCancelledCompositionInput.current = false;
composing.current = false;
compositionBase.current = undefined;
repairEditorDom(selectionRef.current, event.currentTarget);
return;
}
const base = compositionBase.current ?? { segments: latestSegments.current, text: latestText.current };
compositionBase.current = undefined;
composing.current = false;
if (pendingComposition.current) globalThis.clearTimeout(pendingComposition.current.timer);
const endText = (event.currentTarget.textContent ?? '').replace(/\r\n?/g, '\n');
const difference = changedTextRange(base.text, endText);
const endSelection = selectionOffsets(event.currentTarget) ?? {
start: difference.selection.start + difference.replacement.length,
end: difference.selection.start + difference.replacement.length,
};
let snapshot: PendingComposition;
const timer = globalThis.setTimeout(() => {
if (pendingComposition.current === snapshot && editorRef.current) {
finalizePendingComposition(editorRef.current);
}
}, 0);
snapshot = {
baseSegments: base.segments, baseText: base.text, data: event.data,
endSelection, endText, timer,
};
pendingComposition.current = snapshot;
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
if (composing.current) return;
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
event.preventDefault();
clearNativeEventChainGuard();
const liveSelection = selectionOffsets(event.currentTarget);
if (liveSelection) {
selectionRef.current = liveSelection;
setSelectionState(liveSelection);
}
const plain = event.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n');
let richRuns: RichTextSegment[] | undefined;
try {
richRuns = resolveRichTextClipboardFragment(event.clipboardData.getData(RICH_TEXT_CLIPBOARD_MIME), plain);
} catch {
// Some browser/OS clipboard bridges reject unknown MIME reads. Plain text remains safe.
}
const committed = richRuns
? replaceCurrentRuns(richRuns, liveSelection)
: replaceCurrentText(plain, liveSelection);
if (!committed) clearNativeEventChainGuard();
};
const handleCopy = (event: ClipboardEvent<HTMLDivElement>) => {
if (composing.current) return;
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
const liveSelection = selectionOffsets(event.currentTarget);
if (!liveSelection || liveSelection.start === liveSelection.end) return;
const selected = normalizeRuns(sliceRuns(latestSegments.current, liveSelection.start, liveSelection.end));
const plain = documentText(selected);
if (!plain) return;
event.preventDefault();
event.clipboardData.setData('text/plain', plain);
try {
const envelope = registerRichTextClipboardFragment(selected, plain);
if (envelope) event.clipboardData.setData(RICH_TEXT_CLIPBOARD_MIME, envelope);
} catch {
// The plain-text representation still works on clipboard implementations that
// disallow custom MIME types.
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const nativeEvent = event.nativeEvent;
if (composing.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) return;
if (pendingComposition.current) finalizePendingComposition(event.currentTarget);
clearNativeEventChainGuard();
const shortcut = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (shortcut && ['b', 'i', 'u'].includes(key)) {
event.preventDefault();
rememberSelection();
if (key === 'b') applySelectionChange({ bold: !selectedStyle.bold });
if (key === 'i') applySelectionChange({ italic: !selectedStyle.italic });
if (key === 'u') applySelectionChange({ underlined: !selectedStyle.underlined });
return;
}
if (event.key === 'Enter' && !shortcut) {
event.preventDefault();
const liveSelection = editorRef.current ? selectionOffsets(editorRef.current) : undefined;
if (replaceCurrentText('\n', liveSelection)) {
armNativeEventChainGuard(['insertParagraph', 'insertLineBreak']);
}
}
};
const updateSender = (change: Partial<RichTextSender>) => {
if (!messageSender) return;
commit(segments, { sender: { ...messageSender, ...change } });
};
const keepEditorSelection = (event: MouseEvent<HTMLElement>) => event.preventDefault();
const closeVariableBrowser = () => {
setVariableBrowserOpen(false);
globalThis.requestAnimationFrame(() => variableBrowserButtonRef.current?.focus());
};
const insertVariable = (item: RichTextVariableDefinition) => {
if (!replaceCurrentText(variableInsertionExpression(item))) return;
pendingSelectionFocus.current = true;
setVariableBrowserOpen(false);
const restoreEditor = () => {
const root = editorRef.current;
if (!root) return;
root.focus({ preventScroll: true });
restoreSelection(root, selectionRef.current);
};
if (typeof globalThis.requestAnimationFrame === 'function') globalThis.requestAnimationFrame(restoreEditor);
else globalThis.setTimeout(restoreEditor, 0);
};
return <div className={`rich-text-editor${compact ? ' rich-text-editor--compact' : ''}`}>
<div className="rich-text-preview rich-text-composer" aria-label="Minecraft rich text preview">
{previewSender.visible && <span className="rich-text-preview__prefix" contentEditable={false} style={{
color: previewSender.color, fontWeight: previewSender.bold ? 700 : undefined,
fontStyle: previewSender.italic ? 'italic' : undefined, textDecoration: textDecoration(previewSender),
}}>[{previewSender.text || '…'}]{' '}</span>}
<div ref={editorRef} className="rich-text-canvas" role="textbox"
aria-label="富文本内容 / Rich text content" aria-multiline="true" contentEditable
suppressContentEditableWarning spellCheck onInput={handleInput}
onCompositionStart={handleCompositionStart} onCompositionUpdate={handleCompositionUpdate}
onCompositionEnd={handleCompositionEnd} onCopy={handleCopy} onPaste={handlePaste}
onDrop={(event) => event.preventDefault()} onKeyDown={handleKeyDown}
onKeyUp={rememberSelection} onMouseUp={rememberSelection} onFocus={rememberSelection}
data-placeholder="输入消息并框选文字设置样式 / Type a message, then select text to style it" />
</div>
<div className="rich-text-selection-toolbar" aria-label="所选文本工具栏 / Selected text toolbar">
<div className="rich-text-toolbar">
<label className="color-control"><span>所选颜色 / Selection color</span><input type="color"
aria-label="所选文字颜色 / Selected text color" disabled={!hasSelection}
value={selectedColor ?? DEFAULT_SEGMENT.color}
onChange={(event) => applySelectionChange({ color: event.target.value })} /></label>
<StyleButtons value={selectedStyle} update={(change) => applySelectionChange(change)} includeObfuscated
disabled={!hasSelection} onMouseDown={keepEditorSelection} />
</div>
<span className="field__hint" aria-live="polite">{hasSelection
? `已选择 ${currentSelection.end - currentSelection.start} 个字符 / ${currentSelection.end - currentSelection.start} characters selected`
: '请先在上方框选文字 / Select text above to apply formatting'}</span>
</div>
<div className="variable-picker-launcher">
<button ref={variableBrowserButtonRef} type="button" className="variable-picker-launcher__button"
aria-haspopup="dialog" aria-expanded={variableBrowserOpen} onMouseDown={keepEditorSelection}
onClick={() => setVariableBrowserOpen(true)}>
<span><Braces size={14} />插入变量 / Insert variable</span>
<small>可搜索 {availableVariables.length} 项 / Search {availableVariables.length}</small>
</button>
<span className="field__hint">点击浏览变量名称、代码、说明和适用范围 / Browse names, codes, descriptions, and availability</span>
</div>
{variableBrowserOpen && <VariableCatalogDialog variables={availableVariables}
selectionHint="点击任意条目插入到原选区 / Select a variable to insert it at the saved selection"
onClose={closeVariableBrowser} onSelect={insertVariable} />}
<div className="rich-text-selection-details">
<div className="form-grid">
<Field label={<><Link2 size={12} /> 所选文字点击动作 / Selected text click</>}><Select
aria-label="所选文字点击动作 / Selected text click action" disabled={!hasSelection}
value={selectedClickAction ?? '__mixed'}
onChange={(event) => {
const action = event.target.value as RichClickAction;
applySelectionChange({ clickAction: action }, false);
}}>
<option value="__mixed" disabled>多种动作 / Mixed actions</option>
<option value="none">无 / None</option><option value="open_url">打开网页 / Open URL</option>
<option value="run_command">运行玩家命令 / Run command</option>
<option value="suggest_command">填入命令 / Suggest command</option>
<option value="copy_to_clipboard">复制到剪贴板 / Copy to clipboard</option>
</Select></Field>
{selectedClickAction !== 'none' && <Field label="所选动作内容 / Selected action value"
hint={selectedClickSample ? clickValueError(selectedClickSample) : undefined}><Input
aria-label="所选动作内容 / Selected action value" disabled={!hasSelection}
type={selectedClickAction === 'open_url' ? 'url' : 'text'}
pattern={selectedClickAction === 'open_url' ? 'https?://.+'
: selectedClickAction && ['run_command', 'suggest_command'].includes(selectedClickAction) ? '/.*' : undefined}
aria-invalid={Boolean(selectedClickSample && clickValueError(selectedClickSample))}
maxLength={1024} value={selectedClickValue ?? ''}
onChange={(event) => applySelectionChange({ clickValue: event.target.value }, false)}
placeholder={selectedClickAction === undefined ? '多种内容 / Mixed values'
: selectedClickAction === 'open_url' ? 'https://example.com'
: selectedClickAction === 'copy_to_clipboard' ? '留空时复制所选文字 / Empty copies selected text'
: '/help'} /></Field>}
</div>
<div className="form-grid">
<Field label="所选文字悬停说明 / Selected text hover details"><Textarea rows={2} maxLength={1024}
aria-label="所选文字悬停说明 / Selected text hover details" disabled={!hasSelection}
value={selectedHoverText ?? ''} placeholder={selectedHoverText === undefined && hasSelection ? '多种内容 / Mixed values' : ''}
onChange={(event) => applySelectionChange({ hoverText: event.target.value }, false)} /></Field>
<Field label="所选文字 Shift+点击插入内容 / Selected text insertion"><Input maxLength={1024}
aria-label="所选文字插入内容 / Selected text insertion" disabled={!hasSelection}
value={selectedInsertion ?? ''} placeholder={selectedInsertion === undefined && hasSelection ? '多种内容 / Mixed values' : ''}
onChange={(event) => applySelectionChange({ insertion: event.target.value }, false)} /></Field>
</div>
</div>
<div className="field__hint" aria-live="polite">文本字符 / Text characters: {totalCharacters} / {MAX_RICH_TEXT_CHARACTERS}
{' · '}样式段 / Style runs: {segments.length} / {MAX_RICH_TEXT_SEGMENTS}</div>
{(limitError || validationErrors.length > 0) && <div className="form-error" role="alert">
{limitError || validationErrors.join(';')}
</div>}
<details className="rich-text-sender">
<summary><span><Braces size={14} />数值显示 / Number display</span>
<small>{numberPrecision} 位小数 / fraction digits</small></summary>
<div className="rich-text-sender__body">
<Field label="浮点变量保留位数 / Floating-point fraction digits"
hint="默认保留 2 位;只影响消息显示,不会改变传送坐标、金额或指令参数。 / Default: 2. Display only.">
<Select aria-label="浮点变量保留位数 / Floating-point fraction digits" value={numberPrecision}
onChange={(event) => commit(segments, { numberPrecision: Number(event.target.value) })}>
{Array.from({ length: MAX_MESSAGE_NUMBER_PRECISION + 1 }, (_, digits) => <option key={digits} value={digits}>
{digits}</option>)}
</Select>
</Field>
</div>
</details>
<details className="rich-text-sender" open={senderOpen} onToggle={(event) => setSenderOpen(event.currentTarget.open)}>
<summary><span><Braces size={14} />发送者 / Sender</span><small>{inheritsDefaultSender
? `全局默认 / Global default [${previewSender.text}]`
: messageSender.visible ? `[${previewSender.text}]` : '隐藏 / Hidden'}</small></summary>
<div className="rich-text-sender__body">
<label className="toggle-row"><input type="checkbox" aria-label="使用全局默认发送者 / Use global default sender"
checked={inheritsDefaultSender} onChange={(event) => commit(segments, { sender: event.target.checked ? undefined : previewSender })} />
<span><strong>使用全局默认发送者 / Use global default sender</strong><small>[{effectiveDefaultSender}]</small></span></label>
{!inheritsDefaultSender && <>
<label className="toggle-row"><input type="checkbox" checked={messageSender.visible}
onChange={(event) => updateSender({ visible: event.target.checked })} /><span><strong>{messageSender.visible ? '显示发送者' : '隐藏发送者'}</strong><small>控制消息前的 [名称] 前缀</small></span>{messageSender.visible ? <Eye size={15} /> : <EyeOff size={15} />}</label>
{messageSender.visible && <><div className="form-grid"><Field label="发送者名称 / Sender name"><Input maxLength={64}
value={messageSender.text} onChange={(event) => updateSender({ text: event.target.value })} /></Field>
<label className="color-control color-control--field"><span>发送者颜色 / Sender color</span><input type="color"
value={messageSender.color} onChange={(event) => updateSender({ color: event.target.value })} /></label></div>
<div className="rich-text-toolbar"><StyleButtons value={messageSender} update={updateSender} includeObfuscated={false} /></div></>}
</>}
</div>
</details>
</div>;
}
function StyleButtons<T extends { bold: boolean; italic: boolean; underlined: boolean; strikethrough: boolean; obfuscated?: boolean }>({
value, update, includeObfuscated, disabled = false, onMouseDown,
}: {
value: T;
update: (change: Partial<T>) => void;
includeObfuscated: boolean;
disabled?: boolean;
onMouseDown?: (event: MouseEvent<HTMLElement>) => void;
}) {
return <>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.bold ? 'is-active' : ''} aria-pressed={value.bold}
onClick={() => update({ bold: !value.bold } as Partial<T>)} title="粗体 / Bold"><Bold size={15} /></button>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.italic ? 'is-active' : ''} aria-pressed={value.italic}
onClick={() => update({ italic: !value.italic } as Partial<T>)} title="斜体 / Italic"><Italic size={15} /></button>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.underlined ? 'is-active' : ''} aria-pressed={value.underlined}
onClick={() => update({ underlined: !value.underlined } as Partial<T>)} title="下划线 / Underline"><Underline size={15} /></button>
<button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.strikethrough ? 'is-active' : ''} aria-pressed={value.strikethrough}
onClick={() => update({ strikethrough: !value.strikethrough } as Partial<T>)} title="删除线 / Strikethrough"><Strikethrough size={15} /></button>
{includeObfuscated && <button type="button" disabled={disabled} onMouseDown={onMouseDown}
className={value.obfuscated ? 'is-active' : ''} aria-pressed={value.obfuscated}
onClick={() => update({ obfuscated: !value.obfuscated } as Partial<T>)} title="随机字符 / Obfuscated"><Sparkles size={15} /></button>}
</>;
}