feat: integrate AMESim media models and editor UI
This commit is contained in:
1 parent
3e4f466114
commit
e7177ab03e
181 files changed
+7042
-25270
No files matched your search
@@ -0,0 +1,484 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user