initial migration to TS

This commit is contained in:
2026-03-31 22:16:52 -07:00
parent 75167454d0
commit cef5eafa9f
61 changed files with 831 additions and 85 deletions

View File

@@ -8,7 +8,7 @@ import {
measureAngleDegrees,
moveAngleWidget,
round3,
} from './angleMeasureGeometry.js';
} from './angleMeasureGeometry';
function clamp01(value) {
return Math.max(0, Math.min(1, Number(value) || 0));

View File

@@ -20,7 +20,7 @@ import { hydrateWorkflowState } from './workflowHydration';
import useUndoRedo from './useUndoRedo';
import { packWorkflow, unpackWorkflow } from './workflowPacking';
import { serializeWorkflowState } from './workflowSerialization';
import { sortNodesForParentOrder } from './nodeHierarchy.js';
import { sortNodesForParentOrder } from './nodeHierarchy';
import {
buildNodeClipboardPayload,
buildNodeClipboardPayloadForIds,
@@ -38,8 +38,8 @@ import {
beginTrackedNodeRequest,
isTrackedNodeRequestCurrent,
resolveLoadNodeChannelPath,
} from './loadNodeOutputs.js';
import { buildDefaultWidgetValues } from './nodeWidgetDefaults.js';
} from './loadNodeOutputs';
import { buildDefaultWidgetValues } from './nodeWidgetDefaults';
import {
getHandleType,
getInputName,
@@ -52,7 +52,7 @@ import {
outputTypeCanConnectToTarget,
resolveOutputTypeForTarget,
checkConnectionValid,
} from './connectionUtils.js';
} from './connectionUtils';
import {
getSpecTypeAndOptions,
@@ -2624,7 +2624,7 @@ function Flow() {
let nextNodes = currentNodes;
let changed = false;
let structureChanged = false;
const structureChanged = false;
nextNodes = nextNodes.map((candidate) => {
const candidateId = String(candidate.id);

View File

@@ -18,9 +18,9 @@ import TextNoteNode from './TextNoteNode';
import {
getSpecTypeAndOptions, isDataSocketSpec, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
} from './constants';
import { getGroupMinimumSize } from './groupSizing.js';
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js';
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit } from './valueFormatting.js';
import { getGroupMinimumSize } from './groupSizing';
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit } from './valueFormatting';
// ── Context (provided by App) ─────────────────────────────────────────

View File

@@ -9,7 +9,7 @@ import {
parseMarkupShapes,
sanitizeMarkupColor,
sanitizeMarkupShape,
} from './markupShapeGeometry.js';
} from './markupShapeGeometry';
function clampFraction(value) {
const numeric = Number(value);

View File

@@ -1,31 +1,32 @@
const EXCLUDED_CANVAS_TARGETS = '.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container';
const CANVAS_AREA_TARGETS = '.react-flow, .react-flow__renderer, .react-flow__viewport, .react-flow__pane, .react-flow__background, .react-flow__selectionpane';
function getTargetElement(target) {
function getTargetElement(target: EventTarget | null): Element | null {
if (!target) return null;
if (typeof target.closest === 'function') return target;
if (target.parentElement && typeof target.parentElement.closest === 'function') {
return target.parentElement;
if (typeof (target as Element).closest === 'function') return target as Element;
const parent = (target as Node).parentElement;
if (parent && typeof parent.closest === 'function') {
return parent;
}
return null;
}
function supportsClosest(target) {
function supportsClosest(target: EventTarget | null): boolean {
return !!getTargetElement(target);
}
function matchesClosest(target, selector) {
function matchesClosest(target: EventTarget | null, selector: string): boolean {
const element = getTargetElement(target);
return !!element && element.closest(selector) !== null;
}
export function isEditableInteractionTarget(target) {
export function isEditableInteractionTarget(target: EventTarget | null): boolean {
if (!supportsClosest(target)) return false;
if (matchesClosest(target, 'input, textarea, select')) return true;
return matchesClosest(target, '[contenteditable="true"]');
}
export function canStartCanvasRightDragZoomTarget(target) {
export function canStartCanvasRightDragZoomTarget(target: EventTarget | null): boolean {
if (!supportsClosest(target)) return false;
if (isEditableInteractionTarget(target)) return false;
if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) {
@@ -34,7 +35,7 @@ export function canStartCanvasRightDragZoomTarget(target) {
return matchesClosest(target, CANVAS_AREA_TARGETS);
}
export function canOpenCanvasContextMenuTarget(target) {
export function canOpenCanvasContextMenuTarget(target: EventTarget | null): boolean {
if (!supportsClosest(target)) return false;
if (isEditableInteractionTarget(target)) return false;
if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) {
@@ -43,7 +44,7 @@ export function canOpenCanvasContextMenuTarget(target) {
return matchesClosest(target, CANVAS_AREA_TARGETS);
}
export function isSecondaryCanvasContextEvent(event) {
export function isSecondaryCanvasContextEvent(event: MouseEvent | null): boolean {
if (!event || typeof event.button !== 'number') return false;
return event.button === 2 || (event.button === 0 && !!event.ctrlKey);
}

View File

@@ -1,7 +1,7 @@
// ── Connection utility functions ───────────────────────────────────────
// Pure functions extracted from App.jsx so they can be independently tested.
import { socketSpecAcceptsType } from './constants.js';
import { socketSpecAcceptsType } from './constants.ts';
// ── Handle ID helpers ─────────────────────────────────────────────────

View File

@@ -1,3 +1,5 @@
import type { InputSpec, InputOptions } from './types.ts';
// ── Shared type & color constants ─────────────────────────────────────
export const DATA_TYPES = new Set([
@@ -8,7 +10,7 @@ export const DATA_TYPES = new Set([
export const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
export const TYPE_COLORS = {
export const TYPE_COLORS: Record<string, string> = {
DATA_FIELD: '#3a7abf',
IMAGE: '#00ff08a0',
LINE: '#ffbe5c',
@@ -26,7 +28,7 @@ export const TYPE_COLORS = {
DIRECTORY: '#f97316',
};
export const CAT_COLORS = {
export const CAT_COLORS: Record<string, string> = {
Input: '#37474f',
Display: '#212121',
Overlay: '#0f766e',
@@ -39,34 +41,34 @@ export const CAT_COLORS = {
Grains: '#bf360c',
};
export const SOCKET_COMPATIBILITY = {
export const SOCKET_COMPATIBILITY: Record<string, Set<string>> = {
FLOAT: new Set(['INT']),
INT: new Set(['FLOAT']),
LINE: new Set(['COORDPAIR']),
};
const EMPTY_SOCKET_TYPE_SET = new Set();
const EMPTY_SOCKET_TYPE_SET: Set<string> = new Set();
export function getSpecTypeAndOptions(spec) {
export function getSpecTypeAndOptions(spec: InputSpec): [string | string[], InputOptions] {
if (Array.isArray(spec)) {
return [spec[0], spec[1] || {}];
return [spec[0], (spec[1] || {}) as InputOptions];
}
return [spec, {}];
}
export function isDataSocketType(type) {
export function isDataSocketType(type: unknown): boolean {
return typeof type === 'string' && DATA_TYPES.has(type);
}
export function isDataSocketSpec(spec) {
export function isDataSocketSpec(spec: InputSpec): boolean {
const [type] = getSpecTypeAndOptions(spec);
return isDataSocketType(type);
}
export function getAcceptedSocketTypes(specOrType) {
export function getAcceptedSocketTypes(specOrType: InputSpec | string): Set<string> {
const [type, opts] = Array.isArray(specOrType)
? getSpecTypeAndOptions(specOrType)
: [specOrType, {}];
? getSpecTypeAndOptions(specOrType as InputSpec)
: [specOrType, {} as InputOptions];
if (typeof type !== 'string') {
return EMPTY_SOCKET_TYPE_SET;
}
@@ -89,7 +91,7 @@ export function getAcceptedSocketTypes(specOrType) {
return accepted;
}
export function socketSpecAcceptsType(sourceType, targetSpecOrType) {
export function socketSpecAcceptsType(sourceType: string, targetSpecOrType: InputSpec | string): boolean {
if (typeof sourceType !== 'string' || !sourceType) return false;
return getAcceptedSocketTypes(targetSpecOrType).has(sourceType);
}

View File

@@ -1,4 +1,4 @@
import { extractWorkflow } from './pngMetadata.js';
import { extractWorkflow } from './pngMetadata.ts';
const DEFAULT_WORKFLOW_CANDIDATES = [
{ path: '/default-workflow.json', type: 'json' },

View File

@@ -1,4 +1,4 @@
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.js';
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts';
const OMITTED_WIDGET_INPUTS_BY_CLASS = {
View3D: new Set([

View File

@@ -1,6 +1,18 @@
export const GROUP_DRAG_RELEASE_DISTANCE = 18;
export function getPointDistanceOutsideRect(rect, point) {
interface Rect {
left: number;
right: number;
top: number;
bottom: number;
}
interface Point {
x: number;
y: number;
}
export function getPointDistanceOutsideRect(rect: Rect | null, point: Point | null): number {
if (!rect || !point) return Infinity;
const dx = point.x < rect.left
@@ -13,6 +25,6 @@ export function getPointDistanceOutsideRect(rect, point) {
return Math.hypot(dx, dy);
}
export function shouldReleaseFromGroup(rect, point, threshold = GROUP_DRAG_RELEASE_DISTANCE) {
export function shouldReleaseFromGroup(rect: Rect | null, point: Point | null, threshold = GROUP_DRAG_RELEASE_DISTANCE): boolean {
return getPointDistanceOutsideRect(rect, point) >= threshold;
}

View File

@@ -1,7 +1,15 @@
const DEFAULT_CHILD_WIDTH = 200;
const DEFAULT_CHILD_HEIGHT = 120;
function getNodeSize(node, axis) {
interface SizableNode {
position?: { x: number; y: number };
measured?: { width?: number; height?: number };
width?: number;
height?: number;
style?: Record<string, unknown>;
}
function getNodeSize(node: SizableNode | null | undefined, axis: 'width' | 'height'): number {
const fallback = axis === 'width' ? DEFAULT_CHILD_WIDTH : DEFAULT_CHILD_HEIGHT;
const measured = Number(node?.measured?.[axis]);
if (Number.isFinite(measured) && measured > 0) return measured;
@@ -12,7 +20,7 @@ function getNodeSize(node, axis) {
return fallback;
}
export function getGroupMinimumSize(memberNodes, {
export function getGroupMinimumSize(memberNodes: SizableNode[] | null | undefined, {
minWidth = 260,
minHeight = 180,
paddingX = 24,

View File

@@ -1,9 +1,9 @@
export function resolveLoadNodeChannelPath({
explicitPath = null,
resolvedPathInput = null,
explicitPath = null as string | null,
resolvedPathInput = null as string | null,
className = '',
widgetValues = {},
} = {}) {
widgetValues = {} as Record<string, unknown>,
} = {}): string {
if (typeof explicitPath === 'string' && explicitPath) {
return explicitPath;
}
@@ -19,12 +19,12 @@ export function resolveLoadNodeChannelPath({
return '';
}
export function beginTrackedNodeRequest(requestVersions, nodeId) {
export function beginTrackedNodeRequest(requestVersions: Map<string, number>, nodeId: string): number {
const nextVersion = (requestVersions.get(nodeId) || 0) + 1;
requestVersions.set(nodeId, nextVersion);
return nextVersion;
}
export function isTrackedNodeRequestCurrent(requestVersions, nodeId, version) {
export function isTrackedNodeRequestCurrent(requestVersions: Map<string, number>, nodeId: string, version: number): boolean {
return requestVersions.get(nodeId) === version;
}

View File

@@ -1,4 +1,4 @@
import { sortNodesForParentOrder } from './nodeHierarchy.js';
import { sortNodesForParentOrder } from './nodeHierarchy.ts';
export const NODE_CLIPBOARD_KIND = 'tono/node-selection';
export const NODE_CLIPBOARD_MIME = 'application/x-tono-node-selection';

View File

@@ -1,12 +1,18 @@
export function sortNodesForParentOrder(nodes) {
interface NodeLike {
id: string | number;
parentId?: string | number;
[key: string]: unknown;
}
export function sortNodesForParentOrder<T extends NodeLike>(nodes: T[]): T[] {
const list = Array.isArray(nodes) ? nodes.filter(Boolean) : [];
const entries = list.map((node) => ({ id: String(node.id), node }));
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const visiting = new Set();
const visited = new Set();
const ordered = [];
const visiting = new Set<string>();
const visited = new Set<string>();
const ordered: T[] = [];
function visit(entry) {
function visit(entry: { id: string; node: T } | undefined) {
if (!entry) return;
const { id, node } = entry;
if (visited.has(id) || visiting.has(id)) return;

View File

@@ -1,4 +1,4 @@
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.js';
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts';
export function getDefaultWidgetValue(spec) {
const [type, opts] = getSpecTypeAndOptions(spec);

View File

@@ -1,4 +1,4 @@
export function formatUiLabel(text) {
export function formatUiLabel(text: unknown): string {
return String(text ?? '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
@@ -6,7 +6,7 @@ export function formatUiLabel(text) {
.toLowerCase();
}
function normalizeInputNames(raw) {
function normalizeInputNames(raw: unknown): string[] {
if (!raw) return [];
return (Array.isArray(raw) ? raw : [raw])
.map((value) => String(value))

View File

@@ -1,4 +1,4 @@
export function sanitizeRuntimeValuesForPersistence(className, runtimeValues) {
export function sanitizeRuntimeValuesForPersistence(className: string | undefined, runtimeValues: Record<string, unknown> | undefined): Record<string, unknown> {
if (!runtimeValues || typeof runtimeValues !== 'object' || Array.isArray(runtimeValues)) {
return {};
}

235
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,235 @@
import type { Node, Edge } from '@xyflow/react';
import type { CSSProperties } from 'react';
// ── Input Specifications ─────────────────────────────────────────────
export interface InputOptions {
label?: string;
hidden?: boolean;
socket_only?: boolean;
accepted_types?: string[];
default?: unknown;
placeholder?: string;
min?: number;
max?: number;
step?: number;
slider?: boolean;
min_widget?: string;
max_widget?: string;
text_input?: boolean;
color_picker?: boolean;
colormap_stops?: boolean;
set_widgets?: Record<string, unknown>;
show_when_source_type?: Record<string, string[]>;
show_when_widget_value?: Record<string, unknown[]>;
show_when_input_visible?: string | string[];
hide_when_input_connected?: string | string[];
choices_by_source_type?: Record<string, string[]>;
choices_from_table_input?: string;
choices_from_measure_input?: string;
inline_with_input?: string | [string];
source_type_input?: string;
placement?: 'top';
}
/** An input spec is either a bare type string, or a [type, opts] tuple. */
export type InputSpec = string | [type: string | string[], opts?: InputOptions];
// ── Node Definition (from GET /nodes) ────────────────────────────────
export interface NodeDefinition {
input: {
required: Record<string, InputSpec>;
optional: Record<string, InputSpec>;
};
output: string[];
output_name: string[];
output_paths?: string[];
category: string;
manual_trigger?: boolean;
}
export type NodeDefsRegistry = Record<string, NodeDefinition>;
// ── Overlay Types ────────────────────────────────────────────────────
export interface OverlayData {
kind: string;
image?: string;
image_width?: number;
image_height?: number;
x1?: number;
y1?: number;
x2?: number;
y2?: number;
xm?: number;
ym?: number;
a_locked?: boolean;
b_locked?: boolean;
section_title?: string;
line?: number[];
shape?: string;
stroke_color?: string;
stroke_width?: number;
angle_deg?: number;
color?: string;
label_dx?: number;
label_dy?: number;
line_thickness?: number;
histogram?: unknown;
}
// ── Preview Image Types ──────────────────────────────────────────────
export interface PreviewPanel {
kind: 'image' | 'line_plot';
title?: string;
image?: string;
line?: number[];
fallback_image?: string;
}
export interface PreviewPayload {
kind: 'image' | 'line_plot' | 'layer_gallery' | 'panels';
image?: string;
line?: number[];
layers?: Array<{ name?: string; image: string }>;
fallback_image?: string;
panels?: PreviewPanel[];
}
export type PreviewImage = string | PreviewPayload;
// ── Node Data (attached to each ReactFlow node) ──────────────────────
export interface GroupProxy {
handleId: string;
type: string;
label: string;
name: string;
}
export interface NodeData extends Record<string, unknown> {
label: string;
className: string;
definition?: NodeDefinition | null;
widgetValues: Record<string, unknown>;
runtimeValues?: Record<string, unknown>;
// Execution results
previewImage?: PreviewImage | null;
tableRows?: Array<Record<string, unknown>> | null;
scalarValue?: number | { value: number | string; unit?: string } | null;
meshData?: unknown;
overlay?: OverlayData | null;
// Status
error?: string | null;
warning?: string | null;
processingTimeMs?: number | null;
// Group node fields
proxyInputs?: GroupProxy[];
proxyOutputs?: GroupProxy[];
childCount?: number;
collapsed?: boolean;
expandedSize?: { width: number; height: number };
// Serialization extras
extraData?: Record<string, unknown>;
output?: string[];
output_name?: string[];
}
// ── ReactFlow Node & Edge ────────────────────────────────────────────
export type TonoNode = Node<NodeData, 'custom'>;
export type TonoEdge = Edge<{
groupProxyOwner?: string;
groupProxyOriginal?: {
source?: string;
sourceHandle?: string;
target?: string;
targetHandle?: string;
};
}>;
// ── Serialized Workflow ──────────────────────────────────────────────
export interface SerializedNode {
id: string;
type?: string;
position: { x: number; y: number };
width?: number;
height?: number;
className?: string;
parentId?: string;
extent?: [[number, number], [number, number]];
hidden?: boolean;
style?: CSSProperties;
dragHandle?: string;
data: {
label: string;
className: string;
widgetValues: Record<string, unknown>;
runtimeValues?: Record<string, unknown>;
extraData?: Record<string, unknown>;
output?: string[];
output_name?: string[];
};
}
export interface SerializedEdge {
id: string;
source: string;
sourceHandle?: string;
target: string;
targetHandle?: string;
style?: CSSProperties;
hidden?: boolean;
data?: Record<string, unknown>;
}
export interface SerializedWorkflow {
version: number;
nodes: SerializedNode[];
edges: SerializedEdge[];
packed?: boolean;
packedFiles?: Record<string, { filename: string; data: string }>;
}
// ── WebSocket Messages ───────────────────────────────────────────────
export type WsMessage =
| { type: 'execution_start'; data: { prompt_id: string } }
| { type: 'executing'; data: { node: string; prompt_id: string } }
| { type: 'execution_complete'; data: { prompt_id: string } }
| { type: 'execution_error'; data: { node_id: string; message: string } }
| { type: 'preview'; data: { node_id: string; image: PreviewImage } }
| { type: 'table'; data: { node_id: string; rows: Array<Record<string, unknown>> } }
| { type: 'scalar'; data: { node_id: string; value: number | string; unit?: string } }
| { type: 'node_timing'; data: { node_id: string; elapsed_ms: number } }
| { type: 'mesh3d'; data: { node_id: string; mesh: unknown } }
| { type: 'overlay'; data: { node_id: string; overlay: OverlayData } }
| { type: 'node_warning'; data: { node_id: string; message: string } }
| { type: 'nodes_updated'; data: Record<string, never> };
// ── Widget description (used by nodeWidgetLayout) ────────────────────
export interface WidgetDescriptor {
name: string;
type: string | string[];
opts: InputOptions;
socketType?: string;
}
// ── Node Context (provided by App to CustomNode) ─────────────────────
export interface NodeContextValue {
executingNodeId: string | null;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
openFileBrowser: (callback: (files: File[]) => void, options?: unknown) => void;
openHelp: (label: string) => void;
getTableColumns: (nodeId: string, inputName: string) => string[];
getMeasurementChoices: (nodeId: string, inputName: string) => string[];
}

View File

@@ -1,5 +1,5 @@
import { toBlob } from 'html-to-image';
import { CANVAS_COLORS } from './constants.js';
import { CANVAS_COLORS } from './constants.ts';
import { CAPTURE_SELECTOR as linePlotSelector } from './LinePlotOverlay';
import { CAPTURE_SELECTOR as thresholdSelector } from './ThresholdHistogram';
import { CAPTURE_SELECTOR as csSelector } from './CrossSectionOverlay';

View File

@@ -1,5 +1,5 @@
import { sortNodesForParentOrder } from './nodeHierarchy.js';
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.js';
import { sortNodesForParentOrder } from './nodeHierarchy.ts';
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.ts';
function mergeDefinition(nodeData, defs) {
const savedData = nodeData || {};

View File

@@ -5,7 +5,7 @@
* portable across machines and sessions.
*/
import * as api from './api';
import * as api from './api.ts';
const MAX_PACKED_BYTES = 100 * 1024 * 1024; // 100 MB

View File

@@ -1,4 +1,4 @@
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.js';
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.ts';
export function serializeWorkflowState(nodes, edges) {
const compactObject = (value) => {