485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
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<ParameterColumnLayout> | 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<ColumnDragState | null>(null);
|
|
const helpPreviewTimerRef = useRef<number | null>(null);
|
|
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
|
|
const [activeColumnResize, setActiveColumnResize] =
|
|
useState<ColumnName | null>(null);
|
|
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(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<HTMLTableRowElement>,
|
|
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<HTMLDivElement>,
|
|
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<HTMLDivElement>) => {
|
|
const drag = columnDragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
changeColumnLayout(drag.column, event.clientX - drag.startX, drag.startLayout);
|
|
};
|
|
|
|
const stopColumnResize = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
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<HTMLDivElement>,
|
|
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 (
|
|
<div
|
|
aria-label={label}
|
|
aria-orientation="vertical"
|
|
aria-valuemax={Math.round(maximum)}
|
|
aria-valuemin={minimum}
|
|
aria-valuenow={Math.round(currentWidth)}
|
|
aria-valuetext={`${column === "name" ? "参数名" : "单位"}列 ${Math.round(currentWidth)} 像素`}
|
|
className="parameter-table-column-resizer"
|
|
data-column={column}
|
|
onDoubleClick={() => {
|
|
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<HTMLTableRowElement>) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
|
|
dismissHelpPreview();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
|
|
data-resizing-column={activeColumnResize ?? undefined}
|
|
>
|
|
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
|
|
<colgroup>
|
|
<col style={{ width: effectiveLayout.nameWidth }} />
|
|
<col />
|
|
<col style={{ width: effectiveLayout.unitWidth }} />
|
|
</colgroup>
|
|
<thead className="parameter-table-head">
|
|
<tr className="parameter-table-row parameter-table-head-row">
|
|
<th className="parameter-table-cell parameter-table-name-cell" scope="col">
|
|
参数
|
|
{renderColumnResizer("name")}
|
|
</th>
|
|
<th className="parameter-table-cell parameter-table-value-cell" scope="col">
|
|
值
|
|
{renderColumnResizer("unit")}
|
|
</th>
|
|
<th className="parameter-table-cell parameter-table-unit-cell" scope="col">
|
|
单位
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => {
|
|
const hasPreview = rowHasHelpPreview(row);
|
|
const hasUnit = row.unit !== undefined && row.unit !== null;
|
|
return (
|
|
<Fragment key={row.id}>
|
|
<tr
|
|
aria-describedby={
|
|
helpPreview?.rowId === row.id
|
|
? "parameter-help-preview"
|
|
: undefined
|
|
}
|
|
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
|
|
onBlurCapture={rowBlurred}
|
|
onFocusCapture={(event) =>
|
|
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}
|
|
>
|
|
<th
|
|
className="parameter-table-cell parameter-table-name-cell"
|
|
scope="row"
|
|
>
|
|
{row.name}
|
|
</th>
|
|
<td className="parameter-table-cell parameter-table-value-cell">
|
|
{row.value}
|
|
</td>
|
|
<td className="parameter-table-cell parameter-table-unit-cell">
|
|
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
|
|
</td>
|
|
</tr>
|
|
{row.error ? (
|
|
<tr className="parameter-table-message-row">
|
|
<td className="parameter-table-cell" colSpan={3}>
|
|
<small className="field-error">{row.error}</small>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
{helpPreview && previewRow && typeof document !== "undefined"
|
|
? createPortal(
|
|
<div
|
|
className="parameter-help-preview"
|
|
id="parameter-help-preview"
|
|
role="tooltip"
|
|
style={{
|
|
left: helpPreview.left,
|
|
position: "fixed",
|
|
top: helpPreview.top,
|
|
transform: helpPreview.transform,
|
|
}}
|
|
>
|
|
<strong className="parameter-help-preview-title">
|
|
{previewRow.name}
|
|
</strong>
|
|
{previewDescription ? (
|
|
<span className="parameter-help-preview-description">
|
|
{previewDescription}
|
|
</span>
|
|
) : null}
|
|
{previewRow.defaultValue ||
|
|
previewRow.range ||
|
|
previewRow.currentUnit ? (
|
|
<span className="parameter-help-preview-meta">
|
|
{previewRow.defaultValue ? (
|
|
<span>默认值:{previewRow.defaultValue}</span>
|
|
) : null}
|
|
{previewRow.range ? <span>范围:{previewRow.range}</span> : null}
|
|
{previewRow.currentUnit ? (
|
|
<span>当前单位:{previewRow.currentUnit}</span>
|
|
) : null}
|
|
</span>
|
|
) : null}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|