XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
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>}
  </>;
}