import { Fragment, useCallback, useEffect, useRef, useState, } from "react"; import type { CSSProperties, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, } from "react"; import { createPortal } from "react-dom"; export type ParameterColumnLayout = { nameWidth: number; unitWidth: number; }; export type ParameterTableRow = { id: string; name: string; value: ReactNode; unit?: ReactNode; help?: string; error?: string; readOnly?: boolean; description?: string; defaultValue?: string; range?: string; currentUnit?: string; }; type ParameterTableProps = { ariaLabel: string; rows: readonly ParameterTableRow[]; columnLayout: ParameterColumnLayout; availableWidth: number; onColumnLayoutChange: (layout: ParameterColumnLayout) => void; }; type ColumnName = "name" | "unit"; type ColumnDragState = { column: ColumnName; pointerId: number; startX: number; startLayout: ParameterColumnLayout; }; type HelpPreviewState = { rowId: string; left: number; top: number; transform: string; }; type HelpPreviewAnchor = { rowId: string; clientX: number; clientY: number; }; export const PARAMETER_COLUMN_LAYOUT_KEY = "system-simulation-flow:parameter-column-layout"; export const DEFAULT_PARAMETER_COLUMN_LAYOUT: ParameterColumnLayout = { nameWidth: 112, unitWidth: 72, }; const MIN_NAME_WIDTH = 80; const MAX_NAME_WIDTH = 220; const MIN_VALUE_WIDTH = 96; const MIN_UNIT_WIDTH = 48; const MAX_UNIT_WIDTH = 112; const COLUMN_KEYBOARD_STEP = 8; const HELP_PREVIEW_DELAY_MS = 500; const HELP_PREVIEW_GAP = 12; const HELP_PREVIEW_MARGIN = 8; const HELP_PREVIEW_MAX_WIDTH = 300; const HELP_PREVIEW_ESTIMATED_HEIGHT = 156; function boundedWidth(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } export function loadParameterColumnLayout(): ParameterColumnLayout { try { const parsed = JSON.parse( localStorage.getItem(PARAMETER_COLUMN_LAYOUT_KEY) ?? "null", ) as Partial | null; if ( !parsed || !Number.isFinite(parsed.nameWidth) || !Number.isFinite(parsed.unitWidth) ) { return { ...DEFAULT_PARAMETER_COLUMN_LAYOUT }; } return { nameWidth: boundedWidth(Number(parsed.nameWidth), MIN_NAME_WIDTH, MAX_NAME_WIDTH), unitWidth: boundedWidth(Number(parsed.unitWidth), MIN_UNIT_WIDTH, MAX_UNIT_WIDTH), }; } catch { return { ...DEFAULT_PARAMETER_COLUMN_LAYOUT }; } } export function saveParameterColumnLayout(layout: ParameterColumnLayout) { try { localStorage.setItem(PARAMETER_COLUMN_LAYOUT_KEY, JSON.stringify(layout)); } catch { // Column resizing remains available when browser storage is unavailable. } } function effectiveColumnLayout( preferred: ParameterColumnLayout, availableWidth: number, ): ParameterColumnLayout { const usableWidth = Number.isFinite(availableWidth) ? Math.max(MIN_NAME_WIDTH + MIN_VALUE_WIDTH + MIN_UNIT_WIDTH, availableWidth) : MIN_NAME_WIDTH + MIN_VALUE_WIDTH + MIN_UNIT_WIDTH; let nameWidth = boundedWidth(preferred.nameWidth, MIN_NAME_WIDTH, MAX_NAME_WIDTH); let unitWidth = boundedWidth(preferred.unitWidth, MIN_UNIT_WIDTH, MAX_UNIT_WIDTH); let overflow = nameWidth + MIN_VALUE_WIDTH + unitWidth - usableWidth; if (overflow > 0) { const unitReduction = Math.min(overflow, unitWidth - MIN_UNIT_WIDTH); unitWidth -= unitReduction; overflow -= unitReduction; } if (overflow > 0) { nameWidth -= Math.min(overflow, nameWidth - MIN_NAME_WIDTH); } return { nameWidth, unitWidth }; } function rowHasHelpPreview(row: ParameterTableRow) { return Boolean( row.description || (typeof row.help === "string" && row.help) || row.defaultValue || row.range || row.currentUnit, ); } function helpPreviewPosition(clientX: number, clientY: number) { const placeOnRight = clientX + HELP_PREVIEW_GAP + HELP_PREVIEW_MAX_WIDTH <= window.innerWidth - HELP_PREVIEW_MARGIN; const placeBelow = clientY + HELP_PREVIEW_GAP + HELP_PREVIEW_ESTIMATED_HEIGHT <= window.innerHeight - HELP_PREVIEW_MARGIN; return { left: clientX + (placeOnRight ? HELP_PREVIEW_GAP : -HELP_PREVIEW_GAP), top: clientY + (placeBelow ? HELP_PREVIEW_GAP : -HELP_PREVIEW_GAP), transform: `translate(${placeOnRight ? "0" : "-100%"}, ${ placeBelow ? "0" : "-100%" })`, }; } export function ParameterTable({ ariaLabel, rows, columnLayout, availableWidth, onColumnLayoutChange, }: ParameterTableProps) { const effectiveLayout = effectiveColumnLayout(columnLayout, availableWidth); const columnDragRef = useRef(null); const helpPreviewTimerRef = useRef(null); const helpPreviewAnchorRef = useRef(null); const [activeColumnResize, setActiveColumnResize] = useState(null); const [helpPreview, setHelpPreview] = useState(null); const dismissHelpPreview = useCallback(() => { if (helpPreviewTimerRef.current !== null) { window.clearTimeout(helpPreviewTimerRef.current); helpPreviewTimerRef.current = null; } helpPreviewAnchorRef.current = null; setHelpPreview(null); }, []); const scheduleHelpPreview = useCallback( ( target: HTMLElement, row: ParameterTableRow, pointer?: { clientX: number; clientY: number }, ) => { dismissHelpPreview(); if (!rowHasHelpPreview(row)) { return; } const bounds = target.getBoundingClientRect(); const fallbackAnchor = { clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2, }; helpPreviewAnchorRef.current = { rowId: row.id, ...(pointer ?? fallbackAnchor), }; helpPreviewTimerRef.current = window.setTimeout(() => { helpPreviewTimerRef.current = null; const anchor = helpPreviewAnchorRef.current?.rowId === row.id ? helpPreviewAnchorRef.current : { rowId: row.id, ...fallbackAnchor }; setHelpPreview({ rowId: row.id, ...helpPreviewPosition(anchor.clientX, anchor.clientY), }); }, HELP_PREVIEW_DELAY_MS); }, [dismissHelpPreview], ); const trackHelpPointer = ( event: ReactMouseEvent, row: ParameterTableRow, ) => { helpPreviewAnchorRef.current = { rowId: row.id, clientX: event.clientX, clientY: event.clientY, }; }; useEffect(() => { const dismissOnViewportChange = () => dismissHelpPreview(); window.addEventListener("scroll", dismissOnViewportChange, true); window.addEventListener("resize", dismissOnViewportChange); return () => { window.removeEventListener("scroll", dismissOnViewportChange, true); window.removeEventListener("resize", dismissOnViewportChange); if (helpPreviewTimerRef.current !== null) { window.clearTimeout(helpPreviewTimerRef.current); helpPreviewTimerRef.current = null; } }; }, [dismissHelpPreview]); const changeColumnLayout = ( column: ColumnName, boundaryDelta: number, startLayout = effectiveLayout, ) => { const proposed = column === "name" ? { ...startLayout, nameWidth: startLayout.nameWidth + boundaryDelta } : { ...startLayout, unitWidth: startLayout.unitWidth - boundaryDelta }; onColumnLayoutChange(effectiveColumnLayout(proposed, availableWidth)); }; const startColumnResize = ( event: ReactPointerEvent, column: ColumnName, ) => { dismissHelpPreview(); event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); columnDragRef.current = { column, pointerId: event.pointerId, startX: event.clientX, startLayout: effectiveLayout, }; setActiveColumnResize(column); }; const moveColumnResize = (event: ReactPointerEvent) => { const drag = columnDragRef.current; if (!drag || drag.pointerId !== event.pointerId) { return; } changeColumnLayout(drag.column, event.clientX - drag.startX, drag.startLayout); }; const stopColumnResize = (event: ReactPointerEvent) => { if (columnDragRef.current?.pointerId !== event.pointerId) { return; } columnDragRef.current = null; setActiveColumnResize(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; const resizeColumnWithKeyboard = ( event: ReactKeyboardEvent, column: ColumnName, ) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { return; } event.preventDefault(); dismissHelpPreview(); const step = event.shiftKey ? COLUMN_KEYBOARD_STEP * 3 : COLUMN_KEYBOARD_STEP; changeColumnLayout(column, event.key === "ArrowRight" ? step : -step); }; const nameColumnMaximum = Math.max( MIN_NAME_WIDTH, Math.min(MAX_NAME_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.unitWidth), ); const unitColumnMaximum = Math.max( MIN_UNIT_WIDTH, Math.min(MAX_UNIT_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.nameWidth), ); const tableStyle = { "--parameter-name-width": `${effectiveLayout.nameWidth}px`, "--parameter-unit-width": `${effectiveLayout.unitWidth}px`, } as CSSProperties; const previewRow = helpPreview ? rows.find((row) => row.id === helpPreview.rowId) ?? null : null; const previewDescription = previewRow?.description || (typeof previewRow?.help === "string" ? previewRow.help : ""); const renderColumnResizer = (column: ColumnName) => { const currentWidth = column === "name" ? effectiveLayout.nameWidth : effectiveLayout.unitWidth; const minimum = column === "name" ? MIN_NAME_WIDTH : MIN_UNIT_WIDTH; const maximum = column === "name" ? nameColumnMaximum : unitColumnMaximum; const label = column === "name" ? "调整参数名列宽" : "调整单位列宽"; return (
{ dismissHelpPreview(); onColumnLayoutChange({ ...DEFAULT_PARAMETER_COLUMN_LAYOUT }); }} onKeyDown={(event) => resizeColumnWithKeyboard(event, column)} onLostPointerCapture={stopColumnResize} onPointerCancel={stopColumnResize} onPointerDown={(event) => startColumnResize(event, column)} onPointerMove={moveColumnResize} onPointerUp={stopColumnResize} role="separator" tabIndex={0} /> ); }; const rowBlurred = (event: ReactFocusEvent) => { if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) { dismissHelpPreview(); } }; return (
{rows.map((row) => { const hasPreview = rowHasHelpPreview(row); const hasUnit = row.unit !== undefined && row.unit !== null; return ( scheduleHelpPreview(event.currentTarget, row) } onMouseEnter={(event) => scheduleHelpPreview(event.currentTarget, row, { clientX: event.clientX, clientY: event.clientY, }) } onMouseLeave={dismissHelpPreview} onMouseMove={(event) => trackHelpPointer(event, row)} tabIndex={row.readOnly && hasPreview ? 0 : undefined} > {row.error ? ( ) : null} ); })}
参数 {renderColumnResizer("name")} 值 {renderColumnResizer("unit")} 单位
{row.name} {row.value} {hasUnit ? row.unit : —}
{row.error}
{helpPreview && previewRow && typeof document !== "undefined" ? createPortal( , document.body, ) : null}
); }