finalize typescript migration

This commit is contained in:
2026-03-31 23:46:44 -07:00
parent cef5eafa9f
commit ad88c40599
34 changed files with 1390 additions and 917 deletions

View File

@@ -10,17 +10,17 @@ import {
round3,
} from './angleMeasureGeometry';
function clamp01(value) {
function clamp01(value: number) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function sanitizeHexColor(value, fallback = '#ff9800') {
function sanitizeHexColor(value: unknown, fallback = '#ff9800') {
if (typeof value !== 'string') return fallback;
const text = value.trim();
return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback;
}
function hexToRgb(value) {
function hexToRgb(value: string) {
const color = sanitizeHexColor(value);
return {
r: parseInt(color.slice(1, 3), 16),
@@ -29,7 +29,7 @@ function hexToRgb(value) {
};
}
function mixColor(baseColor, mixWith, weight) {
function mixColor(baseColor: string, mixWith: string, weight: number) {
const alpha = Math.max(0, Math.min(1, Number(weight) || 0));
const base = hexToRgb(baseColor);
const target = hexToRgb(mixWith);
@@ -39,13 +39,13 @@ function mixColor(baseColor, mixWith, weight) {
return `rgb(${r}, ${g}, ${b})`;
}
function formatAngle(value) {
function formatAngle(value: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '0.0 deg';
return `${numeric.toFixed(1)} deg`;
}
function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
function buildAngleArcPath(x1: number, y1: number, xm: number, ym: number, x2: number, y2: number) {
const va = { x: x1 - xm, y: y1 - ym };
const vb = { x: x2 - xm, y: y2 - ym };
const lenA = Math.hypot(va.x, va.y);
@@ -63,6 +63,29 @@ function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
].join(' ');
}
interface AngleMeasureOverlayProps {
image: string;
x1: number;
y1: number;
xm: number;
ym: number;
x2: number;
y2: number;
labelDx: number;
labelDy: number;
angleDeg: number;
color: string;
strokeWidth: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
interface AngleDragState {
handle: string;
start?: { fx: number; fy: number };
points?: { x1: number; y1: number; xm: number; ym: number; x2: number; y2: number };
}
export default function AngleMeasureOverlay({
image,
x1,
@@ -78,9 +101,9 @@ export default function AngleMeasureOverlay({
strokeWidth,
nodeId,
onWidgetChange,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null);
}: AngleMeasureOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<AngleDragState | null>(null);
const resolvedColor = sanitizeHexColor(color, '#ff9800');
const resolvedStrokeWidth = Math.max(0.35, Math.min(6, Number(strokeWidth) || 1.35));
const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42);
@@ -88,21 +111,21 @@ export default function AngleMeasureOverlay({
const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72);
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
const getCoords = useCallback((event) => {
const rect = containerRef.current.getBoundingClientRect();
const getCoords = useCallback((event: React.PointerEvent<Element>) => {
const rect = containerRef.current!.getBoundingClientRect();
return {
fx: clamp01((event.clientX - rect.left) / rect.width),
fy: clamp01((event.clientY - rect.top) / rect.height),
};
}, []);
const updateWidgets = useCallback((updates) => {
const updateWidgets = useCallback((updates: Record<string, unknown>) => {
Object.entries(updates).forEach(([name, value]) => {
onWidgetChange(nodeId, name, value);
});
}, [nodeId, onWidgetChange]);
const onPointerDown = useCallback((handle) => (event) => {
const onPointerDown = useCallback((handle: string) => (event: React.PointerEvent<Element>) => {
event.stopPropagation();
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
@@ -120,15 +143,15 @@ export default function AngleMeasureOverlay({
setDragging({ handle });
}, [getCoords, x1, y1, xm, ym, x2, y2]);
const onPointerMove = useCallback((event) => {
const onPointerMove = useCallback((event: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(event);
if (dragging.handle === 'mid') {
updateWidgets(moveAngleWidget(
dragging.points,
fx - dragging.start.fx,
fy - dragging.start.fy,
dragging.points!,
fx - dragging.start!.fx,
fy - dragging.start!.fy,
));
return;
}
@@ -168,7 +191,7 @@ export default function AngleMeasureOverlay({
'--angle-badge-text-color': resolvedBadgeTextColor,
'--angle-badge-border-color': resolvedBadgeBorderColor,
'--angle-stroke-width': `${resolvedStrokeWidth}`,
}}
} as React.CSSProperties}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}

File diff suppressed because it is too large Load Diff

View File

@@ -2,32 +2,44 @@ import React, { useRef, useState, useCallback } from 'react';
export const CAPTURE_SELECTOR = '.crop-overlay';
interface CropBoxOverlayProps {
image: string;
x1: number;
y1: number;
x2: number;
y2: number;
aLocked: boolean;
bLocked: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
export default function CropBoxOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null);
}: CropBoxOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null);
const getCoords = useCallback((e) => {
const rect = containerRef.current.getBoundingClientRect();
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
const rect = containerRef.current!.getBoundingClientRect();
return {
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
};
}, []);
const onPointerDown = useCallback((point) => (e) => {
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return;
e.stopPropagation();
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(point);
}, [aLocked, bLocked]);
const onPointerMove = useCallback((e) => {
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3));

View File

@@ -10,33 +10,47 @@ export const CAPTURE_SELECTOR = '.cs-overlay';
* Marker positions are driven by widget values (immediate React state),
* not by backend overlay coords, so they move instantly during drag.
*/
interface CrossSectionOverlayProps {
image: string;
x1: number;
y1: number;
x2: number;
y2: number;
aLocked: boolean;
bLocked: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
showLine?: boolean;
}
export default function CrossSectionOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
showLine = true,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null); // 'p1' or 'p2'
}: CrossSectionOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null); // 'p1' or 'p2'
const getCoords = useCallback((e) => {
const rect = containerRef.current.getBoundingClientRect();
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
const rect = containerRef.current!.getBoundingClientRect();
return {
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
};
}, []);
const onPointerDown = useCallback((point) => (e) => {
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return;
e.stopPropagation();
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(point);
}, [aLocked, bLocked]);
const onPointerMove = useCallback((e) => {
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3));

File diff suppressed because it is too large Load Diff

View File

@@ -2,18 +2,31 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { marked } from 'marked';
interface Heading {
level: number;
text: string;
id: string;
children: Heading[];
}
interface HelpTab {
label: string;
type: string;
content: string;
}
// ── Parse headings from markdown source ──────────────────────────────
function parseHeadings(md) {
function parseHeadings(md: string): Heading[] {
if (!md) return [];
const headings = [];
const headings: Heading[] = [];
const lines = md.split('\n');
for (const line of lines) {
const m = line.match(/^(#{1,6})\s+(.+)/);
if (m) {
const text = m[2].replace(/[*_`~[\]]/g, '').trim();
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '');
headings.push({ level: m[1].length, text, id });
headings.push({ level: m[1].length, text, id, children: [] });
}
}
return headings;
@@ -21,9 +34,9 @@ function parseHeadings(md) {
// ── Inject id attributes into rendered HTML headings ─────────────────
function injectHeadingIds(html, headings) {
function injectHeadingIds(html: string, headings: Heading[]) {
let idx = 0;
return html.replace(/<(h[1-6])>/gi, (match, tag) => {
return html.replace(/<(h[1-6])>/gi, (match: string, tag: string) => {
if (idx < headings.length) {
return `<${tag} id="${headings[idx++].id}">`;
}
@@ -33,9 +46,9 @@ function injectHeadingIds(html, headings) {
// ── Build a tree from flat heading list ──────────────────────────────
function buildTocTree(headings) {
const root = { children: [] };
const stack = [{ node: root, level: 0 }];
function buildTocTree(headings: Heading[]): Heading[] {
const root: { children: Heading[] } = { children: [] };
const stack: { node: { children: Heading[] }; level: number }[] = [{ node: root, level: 0 }];
for (const h of headings) {
const item = { ...h, children: [] };
while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop();
@@ -47,7 +60,14 @@ function buildTocTree(headings) {
// ── TOC sidebar component ────────────────────────────────────────────
function TocItem({ item, collapsed, onToggle, onNavigate }) {
interface TocItemProps {
item: Heading;
collapsed: Record<string, boolean>;
onToggle: (id: string) => void;
onNavigate: (id: string) => void;
}
function TocItem({ item, collapsed, onToggle, onNavigate }: TocItemProps) {
const hasChildren = item.children.length > 0;
const isCollapsed = collapsed[item.id];
return (
@@ -82,13 +102,18 @@ function TocItem({ item, collapsed, onToggle, onNavigate }) {
);
}
function Toc({ headings, contentRef }) {
const [collapsed, setCollapsed] = useState({});
interface TocProps {
headings: Heading[];
contentRef: React.RefObject<HTMLDivElement | null>;
}
function Toc({ headings, contentRef }: TocProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const tree = useMemo(() => buildTocTree(headings), [headings]);
const onToggle = (id) => setCollapsed((prev) => ({ ...prev, [id]: !prev[id] }));
const onToggle = (id: string) => setCollapsed((prev) => ({ ...prev, [id]: !prev[id] }));
const onNavigate = (id) => {
const onNavigate = (id: string) => {
const el = contentRef.current?.querySelector(`#${CSS.escape(id)}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
@@ -108,14 +133,14 @@ function Toc({ headings, contentRef }) {
// ── Click handler for .md links ──────────────────────────────────────
function useMdLinkHandler(onOpenDoc) {
return (e) => {
const a = e.target.closest('a[href]');
function useMdLinkHandler(onOpenDoc: (filename: string) => void) {
return (e: React.MouseEvent<HTMLElement>) => {
const a = (e.target as HTMLElement).closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (href && /\.md$/i.test(href) && !href.startsWith('http')) {
e.preventDefault();
const filename = href.split('/').pop();
const filename = href.split('/').pop() ?? href;
onOpenDoc(filename);
}
};
@@ -123,14 +148,19 @@ function useMdLinkHandler(onOpenDoc) {
// ── Content pane with TOC ────────────────────────────────────────────
function HelpContent({ content, onOpenDoc }) {
const contentRef = useRef(null);
interface HelpContentProps {
content: string;
onOpenDoc: (filename: string) => void;
}
function HelpContent({ content, onOpenDoc }: HelpContentProps) {
const contentRef = useRef<HTMLDivElement>(null);
const handleClick = useMdLinkHandler(onOpenDoc);
const md = content || '*Loading…*';
const headings = useMemo(() => parseHeadings(md), [md]);
const html = useMemo(() => {
let rendered;
try { rendered = marked.parse(md); } catch { rendered = md; }
let rendered: string;
try { rendered = marked.parse(md) as string; } catch { rendered = md; }
return injectHeadingIds(rendered, headings);
}, [md, headings]);
@@ -150,16 +180,22 @@ function HelpContent({ content, onOpenDoc }) {
// ── Journal tab ──────────────────────────────────────────────────────
function JournalTab({ content, onChange, onOpenDoc }) {
interface JournalTabProps {
content: string;
onChange: (value: string) => void;
onOpenDoc: (filename: string) => void;
}
function JournalTab({ content, onChange, onOpenDoc }: JournalTabProps) {
const [isEditing, setIsEditing] = useState(false);
const contentRef = useRef(null);
const contentRef = useRef<HTMLDivElement>(null);
const handleClick = useMdLinkHandler(onOpenDoc);
let renderedHtml = '';
let headings = [];
let headings: Heading[] = [];
if (!isEditing && content?.trim()) {
headings = parseHeadings(content);
try { renderedHtml = injectHeadingIds(marked.parse(content), headings); } catch { renderedHtml = content; }
try { renderedHtml = injectHeadingIds(marked.parse(content) as string, headings); } catch { renderedHtml = content; }
}
return (
@@ -215,11 +251,21 @@ function JournalTab({ content, onChange, onOpenDoc }) {
// ── Main panel manager ───────────────────────────────────────────────
function HelpPanelManager({ tabs, activeTab, onTabSelect, onTabClose, onTabContentChange, onOpenJournal, onOpenDoc }) {
interface HelpPanelManagerProps {
tabs: HelpTab[];
activeTab: string;
onTabSelect: (label: string) => void;
onTabClose: (label: string) => void;
onTabContentChange: (label: string, value: string) => void;
onOpenJournal: () => void;
onOpenDoc: (filename: string) => void;
}
function HelpPanelManager({ tabs, activeTab, onTabSelect, onTabClose, onTabContentChange, onOpenJournal, onOpenDoc }: HelpPanelManagerProps) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const handler = (e) => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeTab) onTabClose(activeTab);
};
document.addEventListener('keydown', handler);

View File

@@ -13,19 +13,19 @@ const MARKER_STROKE = '#ffffff';
const MARKER_LOCKED_COLOR = '#e91e63';
const MARKER_LABEL_FILL = '#0f172a';
function clamp(v, min, max) {
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
function round3(v) {
function round3(v: number) {
return parseFloat(v.toFixed(3));
}
function trimZeros(text) {
function trimZeros(text: string) {
return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1');
}
function formatTick(value) {
function formatTick(value: number) {
const abs = Math.abs(value);
if (abs === 0) return '0';
if (abs >= 1e4 || abs < 1e-3) {
@@ -37,17 +37,17 @@ function formatTick(value) {
return trimZeros(value.toFixed(3));
}
function makeTicks(min, max, count = 5) {
function makeTicks(min: number, max: number, count = 5) {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
if (min === max) return [min];
const ticks = [];
const ticks: number[] = [];
for (let i = 0; i < count; i += 1) {
ticks.push(min + ((max - min) * i) / (count - 1));
}
return ticks;
}
function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) {
if (!Array.isArray(values) || values.length === 0) {
return [fallbackMin, fallbackMax];
}
@@ -66,6 +66,17 @@ function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
return [min, max];
}
interface LinePlotOverlayProps {
overlay: any;
x1: number;
x2: number;
aLocked: boolean;
bLocked: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
interactive?: boolean;
}
export default function LinePlotOverlay({
overlay,
x1,
@@ -75,9 +86,9 @@ export default function LinePlotOverlay({
nodeId,
onWidgetChange,
interactive = true,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null);
}: LinePlotOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
@@ -110,10 +121,10 @@ export default function LinePlotOverlay({
return () => window.removeEventListener('resize', updateSize);
}, []);
const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
const xValues: number[] = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
? overlay.x_axis
: overlay?.line?.map((_, i) => i) || [];
const yValues = Array.isArray(overlay?.line) ? overlay.line : [];
: overlay?.line?.map((_: unknown, i: number) => i) || [];
const yValues: number[] = Array.isArray(overlay?.line) ? overlay.line : [];
const width = size.width || 320;
const height = size.height || Math.round(width / ASPECT_RATIO);
@@ -128,17 +139,17 @@ export default function LinePlotOverlay({
const yMin = yMinRaw - yPad;
const yMax = yMaxRaw + yPad;
const scaleX = useCallback((value) => {
const scaleX = useCallback((value: number) => {
if (xMax === xMin) return plotLeft + plotWidth / 2;
return plotLeft + ((value - xMin) / (xMax - xMin)) * plotWidth;
}, [plotLeft, plotWidth, xMin, xMax]);
const scaleY = useCallback((value) => {
const scaleY = useCallback((value: number) => {
if (yMax === yMin) return plotTop + plotHeight / 2;
return plotTop + (1 - ((value - yMin) / (yMax - yMin))) * plotHeight;
}, [plotTop, plotHeight, yMin, yMax]);
const pickCursorPoint = useCallback((fraction) => {
const pickCursorPoint = useCallback((fraction: number) => {
if (!xValues.length || !yValues.length) {
return {
x: plotLeft,
@@ -173,7 +184,7 @@ export default function LinePlotOverlay({
const cursorA = pickCursorPoint(x1 ?? overlay?.x1 ?? 0.25);
const cursorB = pickCursorPoint(x2 ?? overlay?.x2 ?? 0.75);
const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
const path = yValues.map((y: number, i: number) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
const xTickCount = Math.max(2, Math.min(5, Math.floor(plotWidth / 70)));
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
const xTicks = makeTicks(xMin, xMax, xTickCount);
@@ -187,7 +198,7 @@ export default function LinePlotOverlay({
const markerRadius = clamp(plotWidth / 42, 5.5, 9);
const markerLabelSize = clamp(plotWidth / 34, 8, 11);
const updateCursor = useCallback((point, event) => {
const updateCursor = useCallback((point: string, event: React.PointerEvent<Element>) => {
if (!interactive || !onWidgetChange || !nodeId) return;
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
@@ -202,7 +213,7 @@ export default function LinePlotOverlay({
}
}, [interactive, nodeId, onWidgetChange, pickCursorPoint, plotLeft, plotWidth]);
const onPointerDown = useCallback((point) => (event) => {
const onPointerDown = useCallback((point: string) => (event: React.PointerEvent<Element>) => {
if (!interactive) return;
if ((point === 'p1' && aLocked) || (point === 'p2' && bLocked)) return;
event.preventDefault();
@@ -211,7 +222,7 @@ export default function LinePlotOverlay({
setDragging(point);
}, [interactive, aLocked, bLocked]);
const onPointerMove = useCallback((event) => {
const onPointerMove = useCallback((event: React.PointerEvent<Element>) => {
if (!dragging) return;
updateCursor(dragging, event);
}, [dragging, updateCursor]);

View File

@@ -11,13 +11,29 @@ import {
sanitizeMarkupShape,
} from './markupShapeGeometry';
function clampFraction(value) {
function clampFraction(value: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, numeric));
}
function ShapeElement({ shape, imageWidth, imageHeight }) {
interface MarkupShape {
kind: string;
color: string;
width: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
interface ShapeElementProps {
shape: MarkupShape;
imageWidth: number;
imageHeight: number;
}
function ShapeElement({ shape, imageWidth, imageHeight }: ShapeElementProps) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth;
@@ -29,11 +45,11 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
const strokeWidth = getMarkupPreviewStrokeWidth(shape.width, imageWidth, imageHeight);
const renderShape = { ...shape, width: strokeWidth };
const common = {
fill: 'none',
fill: 'none' as const,
stroke: shape.color,
strokeWidth,
strokeLinecap: shape.kind === 'arrow' ? 'square' : 'round',
strokeLinejoin: 'round',
strokeLinecap: (shape.kind === 'arrow' ? 'square' : 'round') as 'square' | 'round',
strokeLinejoin: 'round' as const,
};
if (shape.kind === 'line') {
@@ -68,6 +84,16 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
);
}
interface MarkupOverlayProps {
image: string;
shape: string;
strokeColor: string;
strokeWidth: number;
markupShapes: string | MarkupShape[];
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
export default function MarkupOverlay({
image,
shape,
@@ -76,11 +102,11 @@ export default function MarkupOverlay({
markupShapes,
nodeId,
onWidgetChange,
}) {
const containerRef = useRef(null);
const imageRef = useRef(null);
const shapesRef = useRef([]);
const [draftShape, setDraftShape] = useState(null);
}: MarkupOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const shapesRef = useRef<MarkupShape[]>([]);
const [draftShape, setDraftShape] = useState<MarkupShape | null>(null);
const [drawing, setDrawing] = useState(false);
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
@@ -123,7 +149,7 @@ export default function MarkupOverlay({
return undefined;
}, [image]);
const getPoint = useCallback((event) => {
const getPoint = useCallback((event: React.PointerEvent<Element>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
@@ -132,13 +158,13 @@ export default function MarkupOverlay({
};
}, []);
const commitShapes = useCallback((nextShapes) => {
const commitShapes = useCallback((nextShapes: MarkupShape[]) => {
if (!nodeId || !onWidgetChange) return;
onWidgetChange(nodeId, 'markup_shapes', JSON.stringify(nextShapes));
}, [nodeId, onWidgetChange]);
const handlePointerDown = useCallback((event) => {
if (!onWidgetChange || event.target.closest('button')) return;
const handlePointerDown = useCallback((event: React.PointerEvent<Element>) => {
if (!onWidgetChange || (event.target as HTMLElement).closest('button')) return;
const point = getPoint(event);
if (!point) return;
event.preventDefault();
@@ -156,7 +182,7 @@ export default function MarkupOverlay({
});
}, [getPoint, normalizedColor, normalizedShape, normalizedWidth, onWidgetChange]);
const handlePointerMove = useCallback((event) => {
const handlePointerMove = useCallback((event: React.PointerEvent<Element>) => {
if (!drawing) return;
const point = getPoint(event);
if (!point) return;

View File

@@ -1,48 +1,63 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { CANVAS_COLORS } from './constants';
function clampFraction(value) {
interface StrokePoint {
x: number;
y: number;
}
interface Stroke {
size: number;
points: StrokePoint[];
}
interface DrawStrokeStyles {
strokeStyle?: string;
fillStyle?: string;
}
function clampFraction(value: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, numeric));
}
function sanitizeStroke(stroke, fallbackPenSize) {
function sanitizeStroke(stroke: any, fallbackPenSize: number): Stroke | null {
if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points) || stroke.points.length === 0) {
return null;
}
const size = Math.max(1, Math.round(Number(stroke.size) || fallbackPenSize || 1));
const points = stroke.points
.map((point) => {
.map((point: any) => {
if (!point || typeof point !== 'object') return null;
return {
x: Number(clampFraction(point.x).toFixed(4)),
y: Number(clampFraction(point.y).toFixed(4)),
};
})
.filter(Boolean);
.filter(Boolean) as StrokePoint[];
if (points.length === 0) return null;
return { size, points };
}
function parseMaskPaths(maskPaths, fallbackPenSize) {
function parseMaskPaths(maskPaths: any, fallbackPenSize: number): Stroke[] {
if (Array.isArray(maskPaths)) {
return maskPaths.map((stroke) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean);
return maskPaths.map((stroke: any) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean) as Stroke[];
}
if (typeof maskPaths !== 'string' || !maskPaths.trim()) return [];
try {
const parsed = JSON.parse(maskPaths);
if (!Array.isArray(parsed)) return [];
return parsed.map((stroke) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean);
return parsed.map((stroke: any) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean) as Stroke[];
} catch {
return [];
}
}
function drawStroke(ctx, stroke, width, height, imageWidth, imageHeight, styles = {}) {
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, width: number, height: number, imageWidth: number, imageHeight: number, styles: DrawStrokeStyles = {}) {
if (!stroke || !Array.isArray(stroke.points) || stroke.points.length === 0) return;
const scaleX = imageWidth > 0 ? width / imageWidth : 1;
@@ -87,6 +102,16 @@ function drawStroke(ctx, stroke, width, height, imageWidth, imageHeight, styles
ctx.restore();
}
interface MaskPaintOverlayProps {
image: string;
imageWidth: number;
imageHeight: number;
penSize: number;
maskPaths: any;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
export default function MaskPaintOverlay({
image,
imageWidth,
@@ -95,15 +120,15 @@ export default function MaskPaintOverlay({
maskPaths,
nodeId,
onWidgetChange,
}) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const strokesRef = useRef([]);
const draftStrokeRef = useRef(null);
const [strokes, setStrokes] = useState(() => parseMaskPaths(maskPaths, penSize));
const [draftStroke, setDraftStroke] = useState(null);
}: MaskPaintOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const strokesRef = useRef<Stroke[]>([]);
const draftStrokeRef = useRef<Stroke | null>(null);
const [strokes, setStrokes] = useState<Stroke[]>(() => parseMaskPaths(maskPaths, penSize));
const [draftStroke, setDraftStroke] = useState<Stroke | null>(null);
const [drawing, setDrawing] = useState(false);
const [cursorPoint, setCursorPoint] = useState(null);
const [cursorPoint, setCursorPoint] = useState<StrokePoint | null>(null);
useEffect(() => {
const parsed = parseMaskPaths(maskPaths, penSize);
@@ -121,7 +146,7 @@ export default function MaskPaintOverlay({
draftStrokeRef.current = draftStroke;
}, [draftStroke]);
const redrawCanvas = useCallback((committedStrokes, activeStroke) => {
const redrawCanvas = useCallback((committedStrokes: Stroke[], activeStroke: Stroke | null) => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
@@ -154,7 +179,7 @@ export default function MaskPaintOverlay({
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.scale(dpr, dpr);
const drawMaskStroke = (stroke) => drawStroke(
const drawMaskStroke = (stroke: Stroke) => drawStroke(
maskCtx,
stroke,
cssWidth,
@@ -193,7 +218,7 @@ export default function MaskPaintOverlay({
return () => observer.disconnect();
}, [draftStroke, redrawCanvas]);
const getPoint = useCallback((event) => {
const getPoint = useCallback((event: React.PointerEvent<Element>): StrokePoint | null => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
@@ -211,7 +236,7 @@ export default function MaskPaintOverlay({
return Math.max(1, (Math.max(1, Math.round(Number(penSize) || 1)) * brushScale));
}, [imageHeight, imageWidth, penSize]);
const appendPoint = useCallback((stroke, point) => {
const appendPoint = useCallback((stroke: Stroke | null, point: StrokePoint | null): Stroke | null => {
if (!stroke || !point) return stroke;
const lastPoint = stroke.points[stroke.points.length - 1];
if (lastPoint && Math.abs(lastPoint.x - point.x) < 0.001 && Math.abs(lastPoint.y - point.y) < 0.001) {
@@ -223,7 +248,7 @@ export default function MaskPaintOverlay({
};
}, []);
const commitStroke = useCallback((stroke) => {
const commitStroke = useCallback((stroke: Stroke | null) => {
const normalizedStroke = sanitizeStroke(stroke, penSize);
setDraftStroke(null);
setDrawing(false);
@@ -235,8 +260,8 @@ export default function MaskPaintOverlay({
onWidgetChange(nodeId, 'mask_paths', JSON.stringify(nextStrokes));
}, [nodeId, onWidgetChange, penSize]);
const handlePointerDown = useCallback((event) => {
if (event.target.closest('button')) return;
const handlePointerDown = useCallback((event: React.PointerEvent<Element>) => {
if ((event.target as HTMLElement).closest('button')) return;
const point = getPoint(event);
if (!point) return;
@@ -251,7 +276,7 @@ export default function MaskPaintOverlay({
});
}, [getPoint, penSize]);
const handlePointerMove = useCallback((event) => {
const handlePointerMove = useCallback((event: React.PointerEvent<Element>) => {
const point = getPoint(event);
if (!point) return;
setCursorPoint(point);

View File

@@ -10,7 +10,62 @@ const DEFAULT_CAMERA_STATE = {
distance: 1.8,
};
function getFiniteNumber(...values) {
interface MeshData {
width: number;
height: number;
z_data?: string;
colors?: string;
z_min: number;
z_max: number;
z_scale: number;
x_range?: [number, number];
y_range?: [number, number];
positions?: string;
indices?: string;
vertex_colors?: string;
surface_extent_x?: number;
surface_extent_y?: number;
make_solid?: boolean;
}
interface CameraState {
azimuth?: number;
polar?: number;
distance?: number;
}
interface DiagnosticsState {
status: string;
webgl: string;
canvas: string;
mesh: string;
bounds: string;
camera: string;
target: string;
render: string;
error: string;
}
interface ThreeState {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
mesh: THREE.Mesh | null;
animId: number | undefined;
}
interface Props {
meshData: MeshData | null;
nodeId?: string;
widgetValues?: Record<string, unknown>;
runtimeValues?: Record<string, unknown>;
onRuntimeValuesChange?: (nodeId: string, patch: Record<string, unknown>, options: { scheduleRun: boolean }) => void;
}
type TypedArrayConstructor = Float32ArrayConstructor | Uint8ArrayConstructor | Uint32ArrayConstructor;
function getFiniteNumber(...values: (number | string | null | undefined)[]): number | null {
for (const value of values) {
const numeric = Number(value);
if (Number.isFinite(numeric)) {
@@ -20,17 +75,17 @@ function getFiniteNumber(...values) {
return null;
}
function formatNumber(value, digits = 2) {
function formatNumber(value: number | string | null | undefined, digits = 2): string {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(digits) : 'n/a';
}
function formatVector3(value, digits = 2) {
function formatVector3(value: THREE.Vector3 | null | undefined, digits = 2): string {
if (!value) return 'n/a';
return `${formatNumber(value.x, digits)}, ${formatNumber(value.y, digits)}, ${formatNumber(value.z, digits)}`;
}
function areView3dDiagnosticsEnabled() {
function areView3dDiagnosticsEnabled(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage?.getItem(VIEW3D_DIAGNOSTICS_STORAGE_KEY) === '1';
@@ -39,7 +94,7 @@ function areView3dDiagnosticsEnabled() {
}
}
function buildGeometrySignature(meshData) {
function buildGeometrySignature(meshData: MeshData | null): string {
if (!meshData) return '';
const positionSource = String(meshData.positions || meshData.z_data || '');
const indexSource = String(meshData.indices || '');
@@ -65,20 +120,20 @@ function buildGeometrySignature(meshData) {
* meshData: { width, height, z_data (b64 float32), colors (b64 uint8 RGB),
* z_min, z_max, z_scale, x_range, y_range }
*/
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) {
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }: Props) {
const [showDiagnostics] = useState(() => areView3dDiagnosticsEnabled());
const containerRef = useRef(null);
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
const containerRef = useRef<HTMLDivElement>(null);
const threeRef = useRef<ThreeState | null>(null); // { renderer, scene, camera, controls, mesh }
const meshCenterRef = useRef(new THREE.Vector3());
const fitDistanceRef = useRef(DEFAULT_CAMERA_STATE.distance);
const lastGeometrySignatureRef = useRef('');
const syncTimerRef = useRef(null);
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSnapshotRef = useRef('');
const isInsideRef = useRef(false);
const pointerEnteredAtRef = useRef(0);
const lastWheelAtRef = useRef(0);
const gestureStartedInsideRef = useRef(false);
const [diagnostics, setDiagnostics] = useState({
const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({
status: meshData ? 'initializing' : 'waiting for mesh',
webgl: 'pending',
canvas: 'n/a',
@@ -90,17 +145,17 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
error: '',
});
const updateDiagnostics = useCallback((patch) => {
const updateDiagnostics = useCallback((patch: Partial<DiagnosticsState>) => {
if (!showDiagnostics) return;
setDiagnostics((prev) => ({ ...prev, ...patch }));
}, [showDiagnostics]);
// Decode base64 to typed arrays
const decode = useCallback((b64, ArrayType) => {
const decode = useCallback(<T extends TypedArrayConstructor>(b64: string, ArrayType: T): InstanceType<T> => {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new ArrayType(bytes.buffer);
return new ArrayType(bytes.buffer) as InstanceType<T>;
}, []);
const captureViewportSnapshot = useCallback(() => {
@@ -108,11 +163,11 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
if (!canvas) return null;
try {
return canvas.toDataURL('image/png');
} catch (error) {
} catch (error: unknown) {
console.warn('[tono] Failed to capture View3D viewport snapshot', error);
updateDiagnostics({
status: 'snapshot error',
error: error?.message || String(error),
error: error instanceof Error ? error.message : String(error),
});
return null;
}
@@ -130,7 +185,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`,
});
if (!nodeId || !onRuntimeValuesChange) return;
const patch = {};
const patch: Record<string, unknown> = {};
if (snapshot && snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
if (Object.keys(patch).length > 0) {
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
@@ -150,24 +205,24 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
}, delay);
}, [syncViewportState]);
const applyCameraState = useCallback((cameraState = {}) => {
const applyCameraState = useCallback((cameraState: CameraState = {}) => {
const state = threeRef.current;
if (!state) return;
const { camera, controls } = state;
const target = meshCenterRef.current.clone();
const distance = THREE.MathUtils.clamp(
getFiniteNumber(cameraState.distance, fitDistanceRef.current, DEFAULT_CAMERA_STATE.distance),
getFiniteNumber(cameraState.distance, fitDistanceRef.current, DEFAULT_CAMERA_STATE.distance) ?? DEFAULT_CAMERA_STATE.distance,
controls.minDistance,
controls.maxDistance,
);
const spherical = new THREE.Spherical(
distance,
THREE.MathUtils.clamp(
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar) ?? DEFAULT_CAMERA_STATE.polar,
0.01,
Math.PI - 0.01,
),
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth),
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth) ?? DEFAULT_CAMERA_STATE.azimuth,
);
const offset = new THREE.Vector3().setFromSpherical(spherical);
controls.target.copy(target);
@@ -209,7 +264,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
error: '',
});
const handleContextLost = (event) => {
const handleContextLost = (event: Event) => {
event.preventDefault();
updateDiagnostics({
status: 'webgl context lost',
@@ -262,7 +317,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
scene.add(dir2);
// Animation loop
let animId;
let animId: number | undefined;
const animate = () => {
animId = requestAnimationFrame(animate);
controls.update();
@@ -270,7 +325,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
};
animate();
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId } as ThreeState;
applyCameraState({
azimuth: DEFAULT_CAMERA_STATE.azimuth,
polar: DEFAULT_CAMERA_STATE.polar,
@@ -294,7 +349,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
return () => {
ro.disconnect();
cancelAnimationFrame(animId);
if (animId !== undefined) cancelAnimationFrame(animId);
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
controls.removeEventListener('end', handleControlsEnd);
renderer.domElement.removeEventListener('webglcontextlost', handleContextLost, false);
@@ -348,15 +403,20 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
if (threeRef.current.mesh) {
scene.remove(threeRef.current.mesh);
threeRef.current.mesh.geometry.dispose();
threeRef.current.mesh.material.dispose();
const mat = threeRef.current.mesh.material;
if (Array.isArray(mat)) {
mat.forEach((m) => m.dispose());
} else {
mat.dispose();
}
}
// Build geometry
const geom = new THREE.BufferGeometry();
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0);
const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0);
const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0) ?? 1.0;
const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0) ?? 1.0;
if (!posArr) {
const zRange = z_max - z_min || 1;
@@ -365,7 +425,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const idx = iy * nx + ix;
const px = (ix / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX;
const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY;
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
const pz = ((zArr![idx] - z_min) / zRange - 0.5) * z_scale;
positionsArray[idx * 3] = px;
positionsArray[idx * 3 + 1] = pz;
@@ -453,11 +513,11 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
lastGeometrySignatureRef.current = geometrySignature;
}
scheduleViewportSync(0, false);
} catch (error) {
} catch (error: unknown) {
console.error('[tono] View3D mesh build failed', error);
updateDiagnostics({
status: 'mesh build error',
error: error?.message || String(error),
error: error instanceof Error ? error.message : String(error),
});
}
}, [applyCameraState, decode, meshData, scheduleViewportSync, updateDiagnostics]);
@@ -494,7 +554,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
};
// Bubble phase: fires after OrbitControls has already run (or skipped due to enableZoom=false)
const onWheelBubble = (e) => {
const onWheelBubble = (e: WheelEvent) => {
if (threeRef.current) {
threeRef.current.controls.enableZoom = true;
}
@@ -517,7 +577,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
};
}, []);
const onContextMenu = useCallback((e) => {
const onContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);

View File

@@ -2,10 +2,11 @@ import React, { useContext, useRef, useState, useEffect, useCallback, useMemo }
import { NodeResizeControl, useStore } from '@xyflow/react';
import { marked } from 'marked';
import { NodeContext } from './CustomNode';
import type { NodeContextValue } from './types';
marked.use({ breaks: true, gfm: true });
const NOTE_COLORS = {
const NOTE_COLORS: Record<string, { bg: string; border: string; dot: string }> = {
default: { bg: '#1e293b', border: '#334155', dot: '#475569' },
blue: { bg: '#0c1f3d', border: '#1d4ed8', dot: '#3b82f6' },
green: { bg: '#062016', border: '#15803d', dot: '#22c55e' },
@@ -14,16 +15,21 @@ const NOTE_COLORS = {
purple: { bg: '#160c2a', border: '#7c3aed', dot: '#a855f7' },
};
function TextNoteNode({ id, data }) {
const ctx = useContext(NodeContext);
interface TextNoteNodeProps {
id: string;
data: { widgetValues?: Record<string, any>; [key: string]: any };
}
function TextNoteNode({ id, data }: TextNoteNodeProps) {
const ctx = useContext(NodeContext) as NodeContextValue | null;
const [isEditing, setIsEditing] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const textareaRef = useRef(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selected = useStore(
useCallback(
(s) => {
const node = s.nodeLookup?.get(id) || s.nodes?.find((n) => n.id === id);
(s: any) => {
const node = s.nodeLookup?.get(id) || s.nodes?.find((n: any) => n.id === id);
return !!node?.selected;
},
[id],
@@ -35,7 +41,7 @@ function TextNoteNode({ id, data }) {
const palette = NOTE_COLORS[color] ?? NOTE_COLORS.default;
const setField = useCallback(
(name, value) => ctx?.onWidgetChange?.(id, name, value),
(name: string, value: unknown) => ctx?.onWidgetChange?.(id, name, value),
[ctx, id],
);
@@ -45,14 +51,14 @@ function TextNoteNode({ id, data }) {
}
}, [isEditing]);
const onDoubleClick = useCallback((e) => {
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsEditing(true);
}, []);
const onBlur = useCallback(() => setIsEditing(false), []);
const onKeyDown = useCallback((e) => {
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl/Cmd+Enter or Escape finishes editing
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
@@ -62,6 +68,7 @@ function TextNoteNode({ id, data }) {
if (e.key === 'Tab') {
e.preventDefault();
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const next = text.substring(0, start) + ' ' + text.substring(end);

View File

@@ -13,11 +13,11 @@ const MARKER_STROKE = '#ffffff';
const MARKER_LOCKED_COLOR = '#e91e63';
const MARKER_LABEL_FILL = '#0f172a';
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function round4(v) { return parseFloat(v.toFixed(4)); }
function trimZeros(t) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); }
function round4(v: number) { return parseFloat(v.toFixed(4)); }
function trimZeros(t: string) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
function formatTick(value) {
function formatTick(value: number) {
const abs = Math.abs(value);
if (abs === 0) return '0';
if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e');
@@ -27,20 +27,28 @@ function formatTick(value) {
return trimZeros(value.toFixed(3));
}
function makeTicks(min, max, count = 5) {
function makeTicks(min: number, max: number, count = 5) {
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min];
return Array.from({ length: count }, (_, i) => min + (max - min) * i / (count - 1));
return Array.from({ length: count }, (_: unknown, i: number) => min + (max - min) * i / (count - 1));
}
function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) {
if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax];
let min = Infinity, max = -Infinity;
for (const v of values) { if (Number.isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } }
return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax];
}
export default function ThresholdHistogram({ overlay, threshold, thresholdConnected, nodeId, onWidgetChange }) {
const containerRef = useRef(null);
interface ThresholdHistogramProps {
overlay: any;
threshold: number;
thresholdConnected: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
export default function ThresholdHistogram({ overlay, threshold, thresholdConnected, nodeId, onWidgetChange }: ThresholdHistogramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const [size, setSize] = useState({ width: 0 });
@@ -63,9 +71,9 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
return () => window.removeEventListener('resize', update);
}, []);
const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
? overlay.x_axis : overlay?.line?.map((_, i) => i) || [];
const yValues = Array.isArray(overlay?.line) ? overlay.line : [];
const xValues: number[] = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
? overlay.x_axis : overlay?.line?.map((_: unknown, i: number) => i) || [];
const yValues: number[] = Array.isArray(overlay?.line) ? overlay.line : [];
const method = overlay?.method ?? 'absolute';
const locked = (overlay?.locked ?? false) || !!thresholdConnected;
const xMin = overlay?.x_min ?? 0;
@@ -84,12 +92,12 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
const yMin = yMinRaw - yPad;
const yMax = yMaxRaw + yPad;
const scaleX = useCallback((v) => {
const scaleX = useCallback((v: number) => {
if (xExtMax === xExtMin) return plotLeft + plotWidth / 2;
return plotLeft + (v - xExtMin) / (xExtMax - xExtMin) * plotWidth;
}, [plotLeft, plotWidth, xExtMin, xExtMax]);
const scaleY = useCallback((v) => {
const scaleY = useCallback((v: number) => {
if (yMax === yMin) return plotTop + plotHeight / 2;
return plotTop + (1 - (v - yMin) / (yMax - yMin)) * plotHeight;
}, [plotTop, plotHeight, yMin, yMax]);
@@ -116,7 +124,7 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
return scaleY(yValues[best]);
})();
const handleDrag = useCallback((e) => {
const handleDrag = useCallback((e: React.PointerEvent<Element>) => {
if (!onWidgetChange || !nodeId || locked || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const frac = clamp((e.clientX - rect.left - plotLeft) / plotWidth, 0, 1);
@@ -128,7 +136,7 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
onWidgetChange(nodeId, 'threshold', newThreshold);
}, [onWidgetChange, nodeId, locked, plotLeft, plotWidth, method, xMin, xMax]);
const onPointerDown = useCallback((e) => {
const onPointerDown = useCallback((e: React.PointerEvent<Element>) => {
if (locked) return;
e.preventDefault();
e.stopPropagation();
@@ -136,13 +144,13 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
setDragging(true);
}, [locked]);
const onPointerMove = useCallback((e) => {
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (dragging) handleDrag(e);
}, [dragging, handleDrag]);
const onPointerUp = useCallback(() => setDragging(false), []);
const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
const path = yValues.map((y: number, i: number) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
const xTickCount = Math.max(2, Math.min(5, Math.floor(plotWidth / 70)));
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
const xTicks = makeTicks(xExtMin, xExtMax, xTickCount);

View File

@@ -1,12 +1,21 @@
function clamp01(value) {
interface AnglePoints {
x1: number;
y1: number;
xm: number;
ym: number;
x2: number;
y2: number;
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, Number(value) || 0));
}
export function round3(value) {
export function round3(value: number): number {
return Number.parseFloat(Number(value).toFixed(3));
}
export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
export function getAngleLabelBasePosition(x1: number, y1: number, xm: number, ym: number, x2: number, y2: number) {
const va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) };
const lenA = Math.hypot(va.x, va.y);
@@ -31,7 +40,7 @@ export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
};
}
export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
export function getAngleLabelPosition(points: AnglePoints, labelDx: number = 0, labelDy: number = 0) {
const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
return {
x: clamp01(base.x + (Number(labelDx) || 0)),
@@ -39,7 +48,7 @@ export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
};
}
export function moveAngleWidget(points, dx, dy) {
export function moveAngleWidget(points: AnglePoints, dx: number, dy: number) {
const nextDx = Number(dx) || 0;
const nextDy = Number(dy) || 0;
const xs = [points.x1, points.xm, points.x2];
@@ -61,7 +70,7 @@ export function moveAngleWidget(points, dx, dy) {
};
}
export function measureAngleDegrees(x1, y1, xm, ym, x2, y2) {
export function measureAngleDegrees(x1: number, y1: number, xm: number, ym: number, x2: number, y2: number) {
const ax = Number(x1) - Number(xm);
const ay = Number(y1) - Number(ym);
const bx = Number(x2) - Number(xm);

View File

@@ -7,10 +7,10 @@
const SESSION_STORAGE_KEY = 'tono-session-id';
let _sessionId = null;
let _ws = null;
let _handler = null;
let _reconnectTimer = null;
let _sessionId: string | null = null;
let _ws: WebSocket | null = null;
let _handler: ((msg: any) => void) | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | undefined = undefined;
function generateSessionId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
@@ -46,13 +46,13 @@ export function getSessionId() {
return _sessionId;
}
function withSessionHeaders(init = {}) {
function withSessionHeaders(init: RequestInit = {}) {
const headers = new Headers(init.headers || {});
headers.set('X-Argonode-Session', getSessionId());
return { ...init, headers };
}
async function sessionFetch(input, init) {
async function sessionFetch(input: string, init?: RequestInit) {
return fetch(input, withSessionHeaders(init));
}
@@ -62,7 +62,7 @@ export async function getNodes() {
return r.json();
}
export async function getNodeDoc(displayName) {
export async function getNodeDoc(displayName: string) {
const r = await sessionFetch(`/docs?name=${encodeURIComponent(displayName)}`);
if (!r.ok) return null;
return r.text();
@@ -74,7 +74,7 @@ export async function getFiles() {
return r.json();
}
export async function createUploadFolder(relativePath) {
export async function createUploadFolder(relativePath: string) {
const r = await sessionFetch('/upload-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -84,7 +84,7 @@ export async function createUploadFolder(relativePath) {
return r.json();
}
export async function uploadFile(file, { relativePath = '' } = {}) {
export async function uploadFile(file: File, { relativePath = '' } = {}) {
const fd = new FormData();
if (relativePath) fd.append('relative_path', relativePath);
fd.append('file', file);
@@ -96,7 +96,7 @@ export async function uploadFile(file, { relativePath = '' } = {}) {
return r.json();
}
export async function uploadPlugin(file) {
export async function uploadPlugin(file: File) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/upload-plugin', { method: 'POST', body: fd });
@@ -110,13 +110,13 @@ export async function uploadPlugin(file) {
return r.json();
}
export async function getChannels(filepath) {
export async function getChannels(filepath: string) {
const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`);
if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }];
return r.json();
}
export async function getFileContent(path) {
export async function getFileContent(path: string) {
const r = await sessionFetch(`/file-content?path=${encodeURIComponent(path)}`);
if (!r.ok) {
const text = await r.text();
@@ -125,13 +125,13 @@ export async function getFileContent(path) {
return r.arrayBuffer();
}
export async function getFolderFiles(folderpath) {
export async function getFolderFiles(folderpath: string) {
const r = await sessionFetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
if (!r.ok) return [];
return r.json();
}
export async function runPrompt(prompt) {
export async function runPrompt(prompt: Record<string, unknown>) {
const r = await sessionFetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -144,7 +144,7 @@ export async function runPrompt(prompt) {
return r.json();
}
export function setMessageHandler(fn) {
export function setMessageHandler(fn: ((msg: any) => void) | null) {
_handler = fn;
}

View File

@@ -2,26 +2,27 @@
// Pure functions extracted from App.jsx so they can be independently tested.
import { socketSpecAcceptsType } from './constants.ts';
import type { InputSpec, TonoNode } from './types.ts';
// ── Handle ID helpers ─────────────────────────────────────────────────
export function getHandleType(handleId) {
export function getHandleType(handleId: string): string {
return handleId.split('::')[2];
}
export function getInputName(handleId) {
export function getInputName(handleId: string): string {
return handleId.split('::')[1];
}
export function getOutputSlot(handleId) {
export function getOutputSlot(handleId: string): number {
return parseInt(handleId.split('::')[1], 10);
}
export function encodeProxyHandleRef(handleId) {
export function encodeProxyHandleRef(handleId: string): string {
return encodeURIComponent(String(handleId || ''));
}
export function decodeProxyHandleRef(encoded) {
export function decodeProxyHandleRef(encoded: string): string {
try {
return decodeURIComponent(String(encoded || ''));
} catch {
@@ -29,7 +30,7 @@ export function decodeProxyHandleRef(encoded) {
}
}
export function parseGroupProxyHandle(handleId) {
export function parseGroupProxyHandle(handleId: string) {
const text = String(handleId || '');
if (!text.startsWith('group-proxy::')) return null;
const parts = text.split('::');
@@ -42,12 +43,12 @@ export function parseGroupProxyHandle(handleId) {
};
}
export function getConnectionHandleType(handleId) {
export function getConnectionHandleType(handleId: string): string {
const proxy = parseGroupProxyHandle(handleId);
return proxy?.type || getHandleType(handleId);
}
export function getResolvedHandleRef(nodeId, handleId) {
export function getResolvedHandleRef(nodeId: string, handleId: string) {
const proxy = parseGroupProxyHandle(handleId);
return {
nodeId: proxy?.nodeId || nodeId,
@@ -56,7 +57,7 @@ export function getResolvedHandleRef(nodeId, handleId) {
};
}
export function getNodeInputSpecForHandle(node, handleId) {
export function getNodeInputSpecForHandle(node: TonoNode, handleId: string): InputSpec | null {
const definition = node?.data?.definition;
if (!definition?.input) return null;
const inputName = getInputName(handleId);
@@ -67,14 +68,14 @@ export function getNodeInputSpecForHandle(node, handleId) {
// ── Type compatibility ────────────────────────────────────────────────
export function outputTypeCanConnectToTarget(outputType, targetSpecOrType, outputAcceptedTypes = []) {
export function outputTypeCanConnectToTarget(outputType: string, targetSpecOrType: InputSpec | string, outputAcceptedTypes: string[] = []) {
if (socketSpecAcceptsType(outputType, targetSpecOrType)) {
return true;
}
// Polymorphic output: the output socket declares it can also produce the target type
if (outputAcceptedTypes.length > 0) {
const targetType = Array.isArray(targetSpecOrType) ? targetSpecOrType[0] : targetSpecOrType;
if (outputAcceptedTypes.includes(targetType)) return true;
if (outputAcceptedTypes.includes(targetType as string)) return true;
}
return outputType === 'ANNOTATION_SOURCE'
&& !socketSpecAcceptsType('ANNOTATION_SOURCE', targetSpecOrType)
@@ -84,7 +85,7 @@ export function outputTypeCanConnectToTarget(outputType, targetSpecOrType, outpu
);
}
export function resolveOutputTypeForTarget(outputType, targetSpecOrType) {
export function resolveOutputTypeForTarget(outputType: string, targetSpecOrType: InputSpec | string): string {
if (outputType !== 'ANNOTATION_SOURCE') {
return outputType;
}
@@ -104,11 +105,18 @@ export function resolveOutputTypeForTarget(outputType, targetSpecOrType) {
// Extracted from the isValidConnection useCallback so it can be unit-tested
// without a ReactFlow context. Pass a `getNodeFn` that mirrors reactFlow.getNode.
export function checkConnectionValid(connection, getNodeFn) {
interface ConnectionParams {
source: string;
sourceHandle: string;
target: string;
targetHandle: string;
}
export function checkConnectionValid(connection: ConnectionParams, getNodeFn: (id: string) => TonoNode | undefined) {
const srcType = getConnectionHandleType(connection.sourceHandle);
const resolvedTarget = getResolvedHandleRef(connection.target, connection.targetHandle);
const targetNode = getNodeFn(resolvedTarget.nodeId);
const targetSpec = getNodeInputSpecForHandle(targetNode, resolvedTarget.handleId) || resolvedTarget.type;
const targetSpec = (targetNode ? getNodeInputSpecForHandle(targetNode, resolvedTarget.handleId) : null) || resolvedTarget.type;
if (socketSpecAcceptsType(srcType, targetSpec)) return true;
// Polymorphic output: check if the source output declares it can produce the target type
const srcProxy = parseGroupProxyHandle(connection.sourceHandle);
@@ -117,6 +125,7 @@ export function checkConnectionValid(connection, getNodeFn) {
const srcNode = getNodeFn(srcNodeId);
const srcSlot = getOutputSlot(srcHandleId);
const srcAcceptedTypes = srcNode?.data?.definition?.output_accepted_types?.[srcSlot] || [];
const targetType = Array.isArray(targetSpec) ? targetSpec[0] : targetSpec;
const targetTypeRaw = Array.isArray(targetSpec) ? targetSpec[0] : targetSpec;
const targetType = Array.isArray(targetTypeRaw) ? targetTypeRaw[0] : targetTypeRaw;
return Array.isArray(srcAcceptedTypes) && srcAcceptedTypes.includes(targetType);
}

View File

@@ -1,11 +1,20 @@
import { extractWorkflow } from './pngMetadata.ts';
import type { SerializedWorkflow } from './types';
const DEFAULT_WORKFLOW_CANDIDATES = [
interface WorkflowCandidate {
path: string;
type: string;
}
const DEFAULT_WORKFLOW_CANDIDATES: WorkflowCandidate[] = [
{ path: '/default-workflow.json', type: 'json' },
{ path: '/default-workflow.png', type: 'png' },
];
async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
type FetchImpl = typeof fetch;
type ExtractWorkflowFn = (blob: Blob) => Promise<SerializedWorkflow | null>;
async function loadCandidate(candidate: WorkflowCandidate, fetchImpl: FetchImpl, extractWorkflowFn: ExtractWorkflowFn): Promise<SerializedWorkflow | null> {
let response;
try {
response = await fetchImpl(candidate.path, { cache: 'no-store' });
@@ -39,9 +48,9 @@ async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
}
export async function loadDefaultWorkflowAsset({
fetchImpl = fetch,
extractWorkflowFn = extractWorkflow,
} = {}) {
fetchImpl = fetch as FetchImpl,
extractWorkflowFn = extractWorkflow as ExtractWorkflowFn,
}: { fetchImpl?: FetchImpl; extractWorkflowFn?: ExtractWorkflowFn } = {}) {
for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) {
const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn);
if (workflow) {

View File

@@ -1,6 +1,7 @@
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts';
import type { InputSpec, TonoNode, TonoEdge } from './types.ts';
const OMITTED_WIDGET_INPUTS_BY_CLASS = {
const OMITTED_WIDGET_INPUTS_BY_CLASS: Record<string, Set<string>> = {
View3D: new Set([
'camera_azimuth',
'camera_polar',
@@ -11,15 +12,15 @@ const OMITTED_WIDGET_INPUTS_BY_CLASS = {
]),
};
function getInputName(handleId) {
function getInputName(handleId: string): string {
return handleId.split('::')[1];
}
function getOutputSlot(handleId) {
function getOutputSlot(handleId: string): number {
return parseInt(handleId.split('::')[1], 10);
}
function resolveExecutionEdge(edge) {
function resolveExecutionEdge(edge: TonoEdge): TonoEdge {
const original = edge?.data?.groupProxyOriginal;
if (!original) return edge;
return {
@@ -31,8 +32,8 @@ function resolveExecutionEdge(edge) {
};
}
export function getConnectedNodeIds(edges) {
const connectedNodeIds = new Set();
export function getConnectedNodeIds(edges: TonoEdge[]): Set<string> {
const connectedNodeIds = new Set<string>();
for (const edge of edges) {
const resolved = resolveExecutionEdge(edge);
connectedNodeIds.add(resolved.source);
@@ -41,11 +42,11 @@ export function getConnectedNodeIds(edges) {
return connectedNodeIds;
}
function isPreviewLoadNode(node) {
function isPreviewLoadNode(node: TonoNode): boolean {
return ['Image', 'ImageDemo'].includes(node?.data?.className);
}
function hasPreviewLoadSelection(node) {
function hasPreviewLoadSelection(node: TonoNode): boolean {
if (node?.data?.className === 'Image') {
return !!String(node.data?.widgetValues?.filename || '').trim();
}
@@ -55,7 +56,7 @@ function hasPreviewLoadSelection(node) {
return false;
}
function getRunnableNodeIds(nodes, edges) {
function getRunnableNodeIds(nodes: TonoNode[], edges: TonoEdge[]): Set<string> {
const connectedNodeIds = getConnectedNodeIds(edges);
const runnableNodeIds = new Set(connectedNodeIds);
@@ -69,9 +70,9 @@ function getRunnableNodeIds(nodes, edges) {
return runnableNodeIds;
}
export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
export function serializeExecutionGraph(nodes: TonoNode[], edges: TonoEdge[], { excludeManualTrigger = false } = {}) {
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
const prompt = {};
const prompt: Record<string, { class_type: string; inputs: Record<string, unknown> }> = {};
for (const node of nodes) {
if (!runnableNodeIds.has(node.id)) continue;
@@ -81,11 +82,11 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
if (!definition) continue;
if (excludeManualTrigger && definition.manual_trigger) continue;
const inputs = {};
const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) };
const inputs: Record<string, unknown> = {};
const valueBag: Record<string, unknown> = { ...(widgetValues || {}), ...(runtimeValues || {}) };
const omittedInputs = OMITTED_WIDGET_INPUTS_BY_CLASS[className] || null;
const allWidgets = {
const allWidgets: Record<string, InputSpec> = {
...(definition.input.required || {}),
...(definition.input.optional || {}),
};
@@ -103,8 +104,8 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
.map(resolveExecutionEdge)
.filter((edge) => edge.target === node.id);
for (const edge of incoming) {
const inputName = getInputName(edge.targetHandle);
const outputSlot = getOutputSlot(edge.sourceHandle);
const inputName = getInputName(edge.targetHandle!);
const outputSlot = getOutputSlot(edge.sourceHandle!);
inputs[inputName] = [edge.source, outputSlot];
}
@@ -114,18 +115,18 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
return prompt;
}
export function getAutoRunnableNodes(nodes, edges) {
export function getAutoRunnableNodes(nodes: TonoNode[], edges: TonoEdge[]): TonoNode[] {
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
return nodes.filter((node) => runnableNodeIds.has(node.id));
}
export function hasBlockingAutoRunInput(node, edges) {
export function hasBlockingAutoRunInput(node: TonoNode, edges: TonoEdge[]): boolean {
const def = node.data?.definition;
if (!def || def.manual_trigger) return false;
const required = def.input.required || {};
for (const [name, spec] of Object.entries(required)) {
const [type, opts] = getSpecTypeAndOptions(spec);
const [type, opts] = getSpecTypeAndOptions(spec as InputSpec);
const hiddenByConnectedInput = (() => {
const raw = opts?.hide_when_input_connected;
if (!raw) return false;
@@ -133,7 +134,7 @@ export function hasBlockingAutoRunInput(node, edges) {
return inputs.some((inputName) => edges.some(
(edge) => {
const resolved = resolveExecutionEdge(edge);
return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
return resolved.target === node.id && getInputName(resolved.targetHandle!) === String(inputName);
}
));
})();
@@ -144,11 +145,11 @@ export function hasBlockingAutoRunInput(node, edges) {
if (!node.data.widgetValues?.[name]) return true;
continue;
}
if (!isDataSocketSpec(spec)) continue;
if (!isDataSocketSpec(spec as InputSpec)) continue;
const hasEdge = edges.some(
(edge) => {
const resolved = resolveExecutionEdge(edge);
return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
return resolved.target === node.id && getInputName(resolved.targetHandle!) === name;
}
);
if (!hasEdge) return true;

View File

@@ -3,4 +3,4 @@ import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')).render(<App />);
createRoot(document.getElementById('root')!).render(<App />);

View File

@@ -2,26 +2,36 @@ export const MARKUP_DEFAULT_SHAPE = 'arrow';
export const MARKUP_DEFAULT_COLOR = '#ff0000';
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
function clampFraction(value) {
export interface MarkupShape {
kind: string;
x1: number;
y1: number;
x2: number;
y2: number;
width: number;
color: string;
}
function clampFraction(value: unknown): number {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, numeric));
}
export function sanitizeMarkupColor(color, fallback = MARKUP_DEFAULT_COLOR) {
export function sanitizeMarkupColor(color: unknown, fallback: string = MARKUP_DEFAULT_COLOR): string {
if (typeof color !== 'string') return fallback;
const value = color.trim();
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
}
export function sanitizeMarkupShape(
shape,
fallbackShape = MARKUP_DEFAULT_SHAPE,
fallbackColor = MARKUP_DEFAULT_COLOR,
fallbackWidth = 3,
) {
shape: Partial<MarkupShape> | null | undefined,
fallbackShape: string = MARKUP_DEFAULT_SHAPE,
fallbackColor: string = MARKUP_DEFAULT_COLOR,
fallbackWidth: number = 3,
): MarkupShape | null {
if (!shape || typeof shape !== 'object') return null;
const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
const kind = (shape.kind && ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind)) ? shape.kind : fallbackShape;
const x1 = clampFraction(shape.x1);
const y1 = clampFraction(shape.y1);
const x2 = clampFraction(shape.x2);
@@ -39,15 +49,15 @@ export function sanitizeMarkupShape(
}
export function parseMarkupShapes(
markupShapes,
fallbackShape = MARKUP_DEFAULT_SHAPE,
fallbackColor = MARKUP_DEFAULT_COLOR,
fallbackWidth = 3,
) {
markupShapes: unknown,
fallbackShape: string = MARKUP_DEFAULT_SHAPE,
fallbackColor: string = MARKUP_DEFAULT_COLOR,
fallbackWidth: number = 3,
): MarkupShape[] {
if (Array.isArray(markupShapes)) {
return markupShapes
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
.filter((s): s is MarkupShape => s != null);
}
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
@@ -57,13 +67,13 @@ export function parseMarkupShapes(
if (!Array.isArray(parsed)) return [];
return parsed
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
.filter((s): s is MarkupShape => s != null);
} catch {
return [];
}
}
export function getArrowGeometry(shape, imageWidth, imageHeight) {
export function getArrowGeometry(shape: MarkupShape, imageWidth: number, imageHeight: number) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth;
@@ -90,7 +100,7 @@ export function getArrowGeometry(shape, imageWidth, imageHeight) {
};
}
export function getMarkupPreviewStrokeWidth(width, imageWidth, imageHeight) {
export function getMarkupPreviewStrokeWidth(width: number, imageWidth: number, imageHeight: number) {
const normalizedWidth = Math.max(1, Math.round(Number(width) || 1));
const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0);
const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM);

View File

@@ -5,11 +5,11 @@ const FILE_ACCEPT = [
'.ttf', '.otf', '.woff', '.woff2',
].join(',');
function normalizeRelativePath(path) {
function normalizeRelativePath(path: string) {
return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
}
function pickWithInput({ directory = false } = {}) {
function pickWithInput({ directory = false } = {}): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
@@ -38,9 +38,14 @@ function pickWithInput({ directory = false } = {}) {
});
}
async function collectDirectoryEntries(handle, prefix = handle.name) {
const entries = [];
for await (const [name, child] of handle.entries()) {
interface FileEntry {
file: File;
relativePath: string;
}
async function collectDirectoryEntries(handle: FileSystemDirectoryHandle, prefix: string = handle.name): Promise<FileEntry[]> {
const entries: FileEntry[] = [];
for await (const [name, child] of (handle as any).entries()) {
const relativePath = prefix ? `${prefix}/${name}` : name;
if (child.kind === 'file') {
const file = await child.getFile();
@@ -56,8 +61,8 @@ async function collectDirectoryEntries(handle, prefix = handle.name) {
export async function pickNativeFileSelection() {
try {
if (typeof window.showOpenFilePicker === 'function') {
const [handle] = await window.showOpenFilePicker({
if (typeof (window as any).showOpenFilePicker === 'function') {
const [handle] = await (window as any).showOpenFilePicker({
multiple: false,
types: [{
description: 'Supported files',
@@ -74,7 +79,7 @@ export async function pickNativeFileSelection() {
entries: [{ file, relativePath: normalizeRelativePath(file.name) }],
};
}
} catch (error) {
} catch (error: any) {
if (error?.name !== 'AbortError') throw error;
return null;
}
@@ -89,8 +94,8 @@ export async function pickNativeFileSelection() {
export async function pickNativeDirectorySelection() {
try {
if (typeof window.showDirectoryPicker === 'function') {
const handle = await window.showDirectoryPicker();
if (typeof (window as any).showDirectoryPicker === 'function') {
const handle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker();
if (!handle) return null;
const entries = await collectDirectoryEntries(handle, handle.name);
return {
@@ -98,14 +103,14 @@ export async function pickNativeDirectorySelection() {
entries,
};
}
} catch (error) {
} catch (error: any) {
if (error?.name !== 'AbortError') throw error;
return null;
}
const files = await pickWithInput({ directory: true });
if (files.length === 0) return null;
const entries = files.map((file) => ({
const entries = files.map((file: File) => ({
file,
relativePath: normalizeRelativePath(file.webkitRelativePath || file.name),
}));

View File

@@ -1,9 +1,52 @@
import { sortNodesForParentOrder } from './nodeHierarchy.ts';
import type { TonoNode, TonoEdge, NodeData, NodeDefsRegistry } from './types.ts';
export const NODE_CLIPBOARD_KIND = 'tono/node-selection';
export const NODE_CLIPBOARD_MIME = 'application/x-tono-node-selection';
function cloneValue(value) {
interface ClipboardNodeData {
label: string;
className: string;
widgetValues: Record<string, unknown>;
runtimeValues: Record<string, unknown>;
extraData: Record<string, unknown>;
}
interface ClipboardNode {
id: string;
type: string;
position: { x: number; y: number };
width?: number;
height?: number;
className?: string;
parentId?: string;
extent?: unknown;
hidden?: boolean;
style?: unknown;
dragHandle?: string;
data: ClipboardNodeData;
[key: string]: unknown;
}
interface ClipboardEdge {
source: string;
sourceHandle?: string | null;
target: string;
targetHandle?: string | null;
style?: unknown;
hidden?: boolean;
data?: unknown;
[key: string]: unknown;
}
interface ClipboardPayload {
kind: string;
version: number;
nodes: ClipboardNode[];
edges: ClipboardEdge[];
}
function cloneValue<T>(value: T): T {
if (value == null) return value;
if (typeof structuredClone === 'function') {
try {
@@ -15,16 +58,16 @@ function cloneValue(value) {
return JSON.parse(JSON.stringify(value));
}
function clonePlainObject(value) {
function clonePlainObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return cloneValue(value) || {};
return cloneValue(value as Record<string, unknown>) || {};
}
function encodeProxyHandleRef(handleId) {
function encodeProxyHandleRef(handleId: string): string {
return encodeURIComponent(String(handleId || ''));
}
function decodeProxyHandleRef(encoded) {
function decodeProxyHandleRef(encoded: string): string {
try {
return decodeURIComponent(String(encoded || ''));
} catch {
@@ -32,7 +75,7 @@ function decodeProxyHandleRef(encoded) {
}
}
function parseGroupProxyHandle(handleId) {
function parseGroupProxyHandle(handleId: string) {
const text = String(handleId || '');
if (!text.startsWith('group-proxy::')) return null;
const parts = text.split('::');
@@ -45,41 +88,42 @@ function parseGroupProxyHandle(handleId) {
};
}
function hasOwn(obj, key) {
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function remapNodeId(value, idMap) {
function remapNodeId(value: string | null | undefined, idMap: Map<string, string>): string | null | undefined {
if (value == null) return value;
return idMap.get(String(value)) || String(value);
}
function remapGroupProxyHandle(handleId, idMap) {
function remapGroupProxyHandle(handleId: string | null | undefined, idMap: Map<string, string>): string | null | undefined {
if (!handleId) return handleId;
const proxy = parseGroupProxyHandle(handleId);
if (!proxy) return handleId;
return `group-proxy::${proxy.direction}::${remapNodeId(proxy.nodeId, idMap)}::${proxy.type}::${encodeProxyHandleRef(proxy.realHandle)}`;
}
function remapGroupProxyDescriptors(items, idMap) {
function remapGroupProxyDescriptors(items: unknown, idMap: Map<string, string>): unknown {
if (!Array.isArray(items)) return items;
return items.map((item) => {
return items.map((item: Record<string, unknown>) => {
if (!item || typeof item !== 'object') return item;
const nextItem = { ...item };
if (typeof nextItem.key === 'string') {
const separator = nextItem.key.indexOf('::');
const separator = (nextItem.key as string).indexOf('::');
if (separator !== -1) {
const handleId = nextItem.key.slice(separator + 2);
nextItem.key = `${remapNodeId(nextItem.key.slice(0, separator), idMap)}::${remapGroupProxyHandle(handleId, idMap)}`;
const handleId = (nextItem.key as string).slice(separator + 2);
nextItem.key = `${remapNodeId((nextItem.key as string).slice(0, separator), idMap)}::${remapGroupProxyHandle(handleId, idMap)}`;
}
}
if (typeof nextItem.handleId === 'string') {
nextItem.handleId = remapGroupProxyHandle(nextItem.handleId, idMap);
nextItem.handleId = remapGroupProxyHandle(nextItem.handleId as string, idMap);
}
return nextItem;
});
}
function remapClipboardExtraData(extraData, idMap) {
function remapClipboardExtraData(extraData: unknown, idMap: Map<string, string>): Record<string, unknown> {
const nextExtraData = clonePlainObject(extraData);
if (Array.isArray(nextExtraData.proxyInputs)) {
nextExtraData.proxyInputs = remapGroupProxyDescriptors(nextExtraData.proxyInputs, idMap);
@@ -90,34 +134,35 @@ function remapClipboardExtraData(extraData, idMap) {
return nextExtraData;
}
function remapClipboardEdgeData(data, idMap) {
function remapClipboardEdgeData(data: unknown, idMap: Map<string, string>): unknown {
if (!data || typeof data !== 'object' || Array.isArray(data)) return cloneValue(data);
const nextData = cloneValue(data);
const nextData = cloneValue(data) as Record<string, unknown>;
if (hasOwn(nextData, 'groupInternalHiddenBy')) {
nextData.groupInternalHiddenBy = remapNodeId(nextData.groupInternalHiddenBy, idMap);
nextData.groupInternalHiddenBy = remapNodeId(nextData.groupInternalHiddenBy as string, idMap);
}
if (hasOwn(nextData, 'groupProxyOwner')) {
nextData.groupProxyOwner = remapNodeId(nextData.groupProxyOwner, idMap);
nextData.groupProxyOwner = remapNodeId(nextData.groupProxyOwner as string, idMap);
}
const original = nextData.groupProxyOriginal;
if (original && typeof original === 'object' && !Array.isArray(original)) {
if (hasOwn(original, 'source')) original.source = remapNodeId(original.source, idMap);
if (hasOwn(original, 'target')) original.target = remapNodeId(original.target, idMap);
if (hasOwn(original, 'sourceHandle')) {
original.sourceHandle = remapGroupProxyHandle(original.sourceHandle, idMap);
const orig = original as Record<string, unknown>;
if (hasOwn(orig, 'source')) orig.source = remapNodeId(orig.source as string, idMap);
if (hasOwn(orig, 'target')) orig.target = remapNodeId(orig.target as string, idMap);
if (hasOwn(orig, 'sourceHandle')) {
orig.sourceHandle = remapGroupProxyHandle(orig.sourceHandle as string, idMap);
}
if (hasOwn(original, 'targetHandle')) {
original.targetHandle = remapGroupProxyHandle(original.targetHandle, idMap);
if (hasOwn(orig, 'targetHandle')) {
orig.targetHandle = remapGroupProxyHandle(orig.targetHandle as string, idMap);
}
}
return nextData;
}
function collectSelectedNodeIds(nodes, nodeIds) {
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
function collectSelectedNodeIds(nodes: TonoNode[], nodeIds: string[]): Set<string> {
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id: string) => String(id)));
if (selectedIdSet.size === 0) return selectedIdSet;
let changed = true;
@@ -135,7 +180,7 @@ function collectSelectedNodeIds(nodes, nodeIds) {
return selectedIdSet;
}
function extractExtraData(data) {
function extractExtraData(data: NodeData): Record<string, unknown> {
const source = data || {};
return Object.fromEntries(
Object.entries(source).filter(([key]) => ![
@@ -156,11 +201,11 @@ function extractExtraData(data) {
}
export function buildNodeClipboardPayloadForIds(
nodes,
edges,
nodeIds,
nodes: TonoNode[],
edges: TonoEdge[],
nodeIds: string[],
{ includeIncomingExternalEdges = false } = {},
) {
): ClipboardPayload | null {
const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
const selectedNodes = Array.isArray(nodes)
? nodes.filter((node) => selectedIdSet.has(String(node.id)))
@@ -177,7 +222,7 @@ export function buildNodeClipboardPayloadForIds(
))
: [];
const snapDim = (v) => {
const snapDim = (v: number | undefined) => {
const n = Math.round(Number(v));
return Number.isFinite(n) && n > 0 ? n : undefined;
};
@@ -224,7 +269,7 @@ export function buildNodeClipboardPayloadForIds(
};
}
export function buildNodeClipboardPayload(nodes, edges) {
export function buildNodeClipboardPayload(nodes: TonoNode[], edges: TonoEdge[]) {
const selectedNodes = Array.isArray(nodes)
? nodes.filter((node) => node?.selected)
: [];
@@ -233,7 +278,7 @@ export function buildNodeClipboardPayload(nodes, edges) {
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
}
export function parseNodeClipboardPayload(text) {
export function parseNodeClipboardPayload(text: string): ClipboardPayload | null {
if (typeof text !== 'string' || !text.trim()) return null;
try {
@@ -247,10 +292,10 @@ export function parseNodeClipboardPayload(text) {
}
export function instantiateNodeClipboardPayload(
payload,
defs = {},
nextNodeId = 1,
offset = { x: 40, y: 40 },
payload: ClipboardPayload | null,
defs: NodeDefsRegistry = {},
nextNodeId: number = 1,
offset: { x: number; y: number } = { x: 40, y: 40 },
{ keepExternalSources = false } = {},
) {
if (!payload || !Array.isArray(payload.nodes) || payload.nodes.length === 0) {
@@ -260,11 +305,11 @@ export function instantiateNodeClipboardPayload(
const idMap = new Map();
let currentId = Number(nextNodeId) || 1;
payload.nodes.forEach((node) => {
payload.nodes.forEach((node: ClipboardNode) => {
idMap.set(String(node.id), String(currentId++));
});
const nodes = sortNodesForParentOrder(payload.nodes.map((node) => {
const nodes = sortNodesForParentOrder(payload.nodes.map((node: ClipboardNode) => {
const newId = idMap.get(String(node.id));
const className = node.data?.className || '';
const definition = className ? defs[className] || null : null;
@@ -305,11 +350,11 @@ export function instantiateNodeClipboardPayload(
}));
const edges = payload.edges
.filter((edge) => (
.filter((edge: ClipboardEdge) => (
idMap.has(String(edge.target))
&& (idMap.has(String(edge.source)) || keepExternalSources)
))
.map((edge, index) => {
.map((edge: ClipboardEdge, index: number) => {
const source = idMap.get(String(edge.source)) || String(edge.source);
const target = idMap.get(String(edge.target));
return {

View File

@@ -1,6 +1,7 @@
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts';
import type { InputSpec, NodeDefinition } from './types';
export function getDefaultWidgetValue(spec) {
export function getDefaultWidgetValue(spec: InputSpec) {
const [type, opts] = getSpecTypeAndOptions(spec);
if (isDataSocketSpec(spec)) return undefined;
if (type === 'BUTTON') return undefined;
@@ -13,8 +14,8 @@ export function getDefaultWidgetValue(spec) {
return opts?.default ?? '';
}
export function buildDefaultWidgetValues(definition) {
const widgetValues = {};
export function buildDefaultWidgetValues(definition: NodeDefinition | null | undefined) {
const widgetValues: Record<string, unknown> = {};
const required = definition?.input?.required || {};
for (const [name, spec] of Object.entries(required)) {
const value = getDefaultWidgetValue(spec);

View File

@@ -1,3 +1,11 @@
import type { WidgetDescriptor } from './types.ts';
interface DataInputDescriptor {
name: string;
type: string | string[];
label: string;
}
export function formatUiLabel(text: unknown): string {
return String(text ?? '')
.replace(/_/g, ' ')
@@ -13,7 +21,7 @@ function normalizeInputNames(raw: unknown): string[] {
.filter((value) => value.length > 0);
}
export function getWidgetCombinedInputName(widget, dataInputByName) {
export function getWidgetCombinedInputName(widget: WidgetDescriptor | null | undefined, dataInputByName: Map<string, DataInputDescriptor> | null | undefined) {
const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0];
if (explicitInputName && dataInputByName?.has(explicitInputName)) {
return explicitInputName;
@@ -34,8 +42,8 @@ export function getWidgetCombinedInputName(widget, dataInputByName) {
return null;
}
export function buildCombinedInputNameByWidgetName(widgets, dataInputs) {
const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input]));
export function buildCombinedInputNameByWidgetName(widgets: WidgetDescriptor[], dataInputs: DataInputDescriptor[]) {
const dataInputByName = new Map((dataInputs || []).map((input: DataInputDescriptor) => [input.name, input]));
const combinedInputNameByWidgetName = new Map();
for (const widget of widgets || []) {

View File

@@ -17,7 +17,7 @@ for (let i = 0; i < 256; i++) {
crcTable[i] = c;
}
function crc32(bytes) {
function crc32(bytes: Uint8Array) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
@@ -29,7 +29,7 @@ function crc32(bytes) {
const PNG_SIG = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
function isPng(data) {
function isPng(data: Uint8Array) {
if (data.length < 8) return false;
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIG[i]) return false;
@@ -37,17 +37,17 @@ function isPng(data) {
return true;
}
function chunkType(data, offset) {
function chunkType(data: Uint8Array, offset: number) {
return String.fromCharCode(
data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
);
}
function readUint32(data, offset) {
function readUint32(data: Uint8Array, offset: number) {
return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0);
}
function buildChunk(type, payload) {
function buildChunk(type: string, payload: Uint8Array) {
const encoder = new TextEncoder();
const typeBytes = encoder.encode(type);
const forCrc = new Uint8Array(4 + payload.length);
@@ -63,7 +63,7 @@ function buildChunk(type, payload) {
return chunk;
}
function parseTextChunk(type, chunkData) {
function parseTextChunk(type: string, chunkData: Uint8Array) {
const decoder = new TextDecoder();
const keywordEnd = chunkData.indexOf(0);
if (keywordEnd === -1) return null;
@@ -99,7 +99,7 @@ function parseTextChunk(type, chunkData) {
* Embed a workflow object into a PNG blob as an iTXt chunk.
* Returns a new Blob with the metadata inserted before IEND.
*/
export async function embedWorkflow(pngBlob, workflow) {
export async function embedWorkflow(pngBlob: Blob, workflow: Record<string, unknown>) {
const data = new Uint8Array(await pngBlob.arrayBuffer());
if (!isPng(data)) throw new Error('Not a valid PNG file');
@@ -138,7 +138,7 @@ export async function embedWorkflow(pngBlob, workflow) {
* Extract the workflow object from a PNG blob's iTXt chunks.
* Returns the parsed object, or null if no "workflow" key is found.
*/
export async function extractWorkflow(pngBlob) {
export async function extractWorkflow(pngBlob: Blob) {
const data = new Uint8Array(await pngBlob.arrayBuffer());
if (!isPng(data)) return null;

View File

@@ -19,6 +19,7 @@ export interface InputOptions {
text_input?: boolean;
color_picker?: boolean;
colormap_stops?: boolean;
top_socket_input?: string | string[];
set_widgets?: Record<string, unknown>;
show_when_source_type?: Record<string, string[]>;
show_when_widget_value?: Record<string, unknown[]>;
@@ -45,6 +46,7 @@ export interface NodeDefinition {
output: string[];
output_name: string[];
output_paths?: string[];
output_accepted_types?: string[][];
category: string;
manual_trigger?: boolean;
}

View File

@@ -1,4 +1,11 @@
import { useRef, useCallback } from 'react';
import { useRef, useCallback, type MutableRefObject } from 'react';
import type { TonoNode, TonoEdge } from './types';
interface Snapshot {
nodes: TonoNode[];
edges: TonoEdge[];
nextId: number;
}
/**
* Snapshot-based undo/redo for nodes + edges.
@@ -7,10 +14,10 @@ import { useRef, useCallback } from 'react';
* Call `undo` / `redo` to restore.
*/
export default function useUndoRedo({ maxHistory = 50 } = {}) {
const pastRef = useRef([]);
const futureRef = useRef([]);
const pastRef = useRef<Snapshot[]>([]);
const futureRef = useRef<Snapshot[]>([]);
const pushSnapshot = useCallback((nodes, edges, nextId) => {
const pushSnapshot = useCallback((nodes: TonoNode[], edges: TonoEdge[], nextId: number) => {
pastRef.current = [
...pastRef.current.slice(-(maxHistory - 1)),
{
@@ -22,7 +29,7 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
futureRef.current = [];
}, [maxHistory]);
const undo = useCallback((setNodes, setEdges, nextIdRef, getNodes, getEdges) => {
const undo = useCallback((setNodes: (n: TonoNode[]) => void, setEdges: (e: TonoEdge[]) => void, nextIdRef: MutableRefObject<number>, getNodes: () => TonoNode[], getEdges: () => TonoEdge[]) => {
if (pastRef.current.length === 0) return false;
futureRef.current = [
...futureRef.current,
@@ -32,14 +39,14 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
nextId: nextIdRef.current,
},
];
const snapshot = pastRef.current.pop();
const snapshot = pastRef.current.pop()!;
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
nextIdRef.current = snapshot.nextId;
return true;
}, []);
const redo = useCallback((setNodes, setEdges, nextIdRef, getNodes, getEdges) => {
const redo = useCallback((setNodes: (n: TonoNode[]) => void, setEdges: (e: TonoEdge[]) => void, nextIdRef: MutableRefObject<number>, getNodes: () => TonoNode[], getEdges: () => TonoEdge[]) => {
if (futureRef.current.length === 0) return false;
pastRef.current = [
...pastRef.current,
@@ -49,7 +56,7 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
nextId: nextIdRef.current,
},
];
const snapshot = futureRef.current.pop();
const snapshot = futureRef.current.pop()!;
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
nextIdRef.current = snapshot.nextId;

View File

@@ -1,4 +1,4 @@
const SI_PREFIX_MULTIPLIERS = {
const SI_PREFIX_MULTIPLIERS: Record<string, number> = {
Y: 1e24, Z: 1e21, E: 1e18, P: 1e15, T: 1e12,
G: 1e9, M: 1e6, k: 1e3,
m: 1e-3, u: 1e-6, µ: 1e-6, n: 1e-9, p: 1e-12,
@@ -16,7 +16,7 @@ const PREFIXABLE_UNITS = new Set([
* Returns null if the string does not start with a valid number.
* The numeric value is scaled to the base SI unit via the prefix.
*/
export function parseNumberWithUnit(text) {
export function parseNumberWithUnit(text: unknown) {
const s = String(text ?? '').trim();
if (!s) return { numeric: 0, unit: '' };
@@ -60,7 +60,7 @@ const SI_PREFIXES = [
{ exp: 24, prefix: 'Y' },
];
const SUPERSCRIPT_DIGITS = {
const SUPERSCRIPT_DIGITS: Record<string, string> = {
'-': '⁻',
'0': '⁰',
'1': '¹',
@@ -74,7 +74,7 @@ const SUPERSCRIPT_DIGITS = {
'9': '⁹',
};
export function formatNumericCell(value) {
export function formatNumericCell(value: unknown) {
if (value == null) return '';
if (typeof value === 'number') {
if (!Number.isFinite(value)) return String(value);
@@ -87,18 +87,18 @@ export function formatNumericCell(value) {
return String(value);
}
function toSuperscript(text) {
function toSuperscript(text: string | number) {
return String(text)
.split('')
.map((char) => SUPERSCRIPT_DIGITS[char] || char)
.join('');
}
export function formatDisplayUnit(unit) {
export function formatDisplayUnit(unit: unknown) {
return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent));
}
function parsePrefixableUnit(unit) {
function parsePrefixableUnit(unit: unknown) {
const text = String(unit ?? '').trim();
if (!text) return null;
@@ -113,11 +113,11 @@ function parsePrefixableUnit(unit) {
return { baseUnit: text, power: 1 };
}
function formatPrefixedUnit(baseUnit, prefix, power) {
function formatPrefixedUnit(baseUnit: string, prefix: string, power: number) {
return power === 1 ? `${prefix}${baseUnit}` : `${prefix}${baseUnit}${toSuperscript(power)}`;
}
function choosePrefixExponent(value, power) {
function choosePrefixExponent(value: number, power: number) {
const abs = Math.abs(value);
const candidates = SI_PREFIXES.map(({ exp, prefix }) => {
const scaled = value / (10 ** (exp * power));
@@ -147,7 +147,7 @@ function choosePrefixExponent(value, power) {
* and prefixed unit label to use for a whole axis.
* All tick values should be divided by `scale` before display, and `unitLabel` shown once.
*/
export function getAxisScale(representativeValue, unit) {
export function getAxisScale(representativeValue: unknown, unit: string) {
if (!unit || typeof representativeValue !== 'number' || !Number.isFinite(representativeValue) || representativeValue === 0) {
return { scale: 1, unitLabel: unit || '' };
}
@@ -157,7 +157,7 @@ export function getAxisScale(representativeValue, unit) {
return { scale: representativeValue / scaled, unitLabel: unitText };
}
export function applySIPrefix(value, unit) {
export function applySIPrefix(value: unknown, unit: unknown) {
const formattedUnit = formatDisplayUnit(unit);
if (typeof value !== 'number' || !Number.isFinite(value)) {
return { valueText: formatNumericCell(value), unitText: formattedUnit };
@@ -178,17 +178,17 @@ export function applySIPrefix(value, unit) {
};
}
function getCompanionUnitColumn(column, row) {
function getCompanionUnitColumn(column: unknown, row: unknown) {
if (!row || typeof row !== 'object' || typeof column !== 'string' || column === 'unit') {
return null;
}
const unitColumn = `${column}_unit`;
return typeof row?.[unitColumn] === 'string' ? unitColumn : null;
return typeof (row as Record<string, unknown>)?.[unitColumn] === 'string' ? unitColumn : null;
}
export function getTableColumns(rows) {
const columns = [];
const hiddenColumns = new Set();
export function getTableColumns(rows: Array<Record<string, unknown>> | null | undefined) {
const columns: string[] = [];
const hiddenColumns = new Set<string>();
for (const row of rows || []) {
if (!row || typeof row !== 'object') continue;
@@ -208,7 +208,7 @@ export function getTableColumns(rows) {
return columns.filter((column) => !hiddenColumns.has(column));
}
export function formatTableRowCell(row, column) {
export function formatTableRowCell(row: Record<string, unknown>, column: string) {
const companionUnitColumn = getCompanionUnitColumn(column, row);
if (companionUnitColumn) {
const formatted = applySIPrefix(row?.[column], row?.[companionUnitColumn]);

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,22 +1,21 @@
import { toBlob } from 'html-to-image';
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';
import { CAPTURE_SELECTOR as cropSelector } from './CropBoxOverlay';
import { CAPTURE_SELECTOR as markupSelector } from './MarkupOverlay';
import { CAPTURE_SELECTOR as angleSelector } from './AngleMeasureOverlay';
// Assembled from each overlay component's CAPTURE_SELECTOR export.
// To register a new overlay: export CAPTURE_SELECTOR from its file and add
// an import + entry here. Missing entries produce corrupt ~68-byte PNG output.
// Mirror the CAPTURE_SELECTOR values from each overlay component.
// Duplicated here so workflowCapture.ts stays a plain .ts file that
// Node can run without a JSX transform (needed for tests).
// To register a new overlay, add its selector string here AND export
// CAPTURE_SELECTOR from the overlay component file.
export const OVERLAY_CAPTURE_SELECTORS = [
...new Set([linePlotSelector, thresholdSelector, csSelector, cropSelector, markupSelector, angleSelector]),
'.lineplot-overlay', // LinePlotOverlay + ThresholdHistogram
'.cs-overlay', // CrossSectionOverlay
'.crop-overlay', // CropBoxOverlay
'.markup-overlay', // MarkupOverlay
'.angle-overlay', // AngleMeasureOverlay
];
function encodeBase64(bytes) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64');
function encodeBase64(bytes: Uint8Array) {
if (typeof (globalThis as any).Buffer !== 'undefined') {
return (globalThis as any).Buffer.from(bytes).toString('base64');
}
let binary = '';
@@ -26,13 +25,13 @@ function encodeBase64(bytes) {
return btoa(binary);
}
async function blobToDataUrl(blob) {
async function blobToDataUrl(blob: Blob | null) {
if (!blob) return null;
const bytes = new Uint8Array(await blob.arrayBuffer());
return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`;
}
function getElementSize(el) {
function getElementSize(el: HTMLElement) {
const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 };
return {
width: Math.max(1, Math.round(el.clientWidth || rect.width || 0)),
@@ -40,7 +39,7 @@ function getElementSize(el) {
};
}
export async function waitForImageElement(img) {
export async function waitForImageElement(img: HTMLImageElement) {
if (img.complete && img.naturalWidth > 0) return;
if (typeof img.decode === 'function') {
try {
@@ -50,7 +49,7 @@ export async function waitForImageElement(img) {
// Fall back to load/error listeners below.
}
}
await new Promise((resolve) => {
await new Promise<void>((resolve) => {
const done = () => {
img.removeEventListener('load', done);
img.removeEventListener('error', done);
@@ -61,7 +60,7 @@ export async function waitForImageElement(img) {
});
}
export async function getCaptureImageDataUrl(img) {
export async function getCaptureImageDataUrl(img: HTMLImageElement) {
const src = img.currentSrc || img.src;
if (!src) return null;
if (!src.startsWith('data:')) return src;
@@ -85,9 +84,9 @@ export async function getCaptureImageDataUrl(img) {
}
export function createCapturePlaceholder(
el,
dataUrl,
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) } = {},
el: HTMLElement,
dataUrl: string,
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) }: { stretch?: boolean; documentRef?: Document; getComputedStyleFn?: typeof getComputedStyle } = {},
) {
const rect = el.getBoundingClientRect();
const style = getComputedStyleFn(el);
@@ -110,7 +109,7 @@ export function createCapturePlaceholder(
return placeholder;
}
async function renderCanvasToDataUrl(canvas) {
async function renderCanvasToDataUrl(canvas: HTMLCanvasElement) {
try {
return canvas.toDataURL('image/png');
} catch {
@@ -118,7 +117,7 @@ async function renderCanvasToDataUrl(canvas) {
}
}
async function renderElementToDataUrl(el, toBlobImpl) {
async function renderElementToDataUrl(el: HTMLElement, toBlobImpl: typeof toBlob) {
const { width, height } = getElementSize(el);
const blob = await toBlobImpl(el, {
width,
@@ -133,7 +132,7 @@ async function renderElementToDataUrl(el, toBlobImpl) {
return blobToDataUrl(blob);
}
async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPlaceholderFn, stretch) {
async function replaceElementsWithPlaceholders(elements: HTMLElement[], renderDataUrl: (el: HTMLElement) => Promise<string | null>, createPlaceholderFn: (el: HTMLElement, dataUrl: string, opts: { stretch: boolean }) => HTMLElement, stretch: boolean) {
const restorers = [];
for (const el of elements) {
@@ -153,21 +152,33 @@ async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPl
return restorers;
}
function defaultQueryAll(root, selector) {
return Array.from(root.querySelectorAll(selector));
function defaultQueryAll(root: HTMLElement, selector: string): HTMLElement[] {
return Array.from(root.querySelectorAll(selector)) as HTMLElement[];
}
function defaultNextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
export async function captureViewportBlob(viewportEl, options, deps = {}) {
interface CaptureViewportDeps {
queryAll?: (root: HTMLElement, selector: string) => HTMLElement[];
toBlobImpl?: typeof toBlob;
waitForImageElement?: (img: HTMLImageElement) => Promise<void>;
renderImageToDataUrl?: (img: HTMLImageElement) => Promise<string | null>;
renderCanvasToDataUrl?: (canvas: HTMLCanvasElement) => Promise<string | null>;
renderOverlayToDataUrl?: (el: HTMLElement) => Promise<string | null>;
createPlaceholder?: (el: HTMLElement, dataUrl: string, opts: { stretch: boolean }) => HTMLElement;
nextFrame?: () => Promise<void>;
overlaySelectors?: string[];
}
export async function captureViewportBlob(viewportEl: HTMLElement, options: Record<string, unknown>, deps: CaptureViewportDeps = {}) {
const queryAll = deps.queryAll ?? defaultQueryAll;
const toBlobImpl = deps.toBlobImpl ?? toBlob;
const waitForImage = deps.waitForImageElement ?? waitForImageElement;
const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl;
const renderCanvas = deps.renderCanvasToDataUrl ?? renderCanvasToDataUrl;
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el) => renderElementToDataUrl(el, toBlobImpl));
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el: HTMLElement) => renderElementToDataUrl(el, toBlobImpl));
const createPlaceholderFn = deps.createPlaceholder ?? createCapturePlaceholder;
const nextFrame = deps.nextFrame ?? defaultNextFrame;
const overlaySelectors = deps.overlaySelectors ?? OVERLAY_CAPTURE_SELECTORS;
@@ -183,13 +194,13 @@ export async function captureViewportBlob(viewportEl, options, deps = {}) {
}
}
const images = queryAll(viewportEl, 'img');
const images = queryAll(viewportEl, 'img') as HTMLImageElement[];
await Promise.all(images.map(waitForImage));
try {
restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage, createPlaceholderFn, false));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas, createPlaceholderFn, true));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage as (el: HTMLElement) => Promise<string | null>, createPlaceholderFn, false));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas as (el: HTMLElement) => Promise<string | null>, createPlaceholderFn, true));
await nextFrame();
await nextFrame();

View File

@@ -1,29 +1,30 @@
import { sortNodesForParentOrder } from './nodeHierarchy.ts';
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.ts';
import type { NodeDefsRegistry, NodeDefinition, InputSpec, SerializedWorkflow } from './types';
function mergeDefinition(nodeData, defs) {
function mergeDefinition(nodeData: Record<string, unknown> | undefined, defs: NodeDefsRegistry): NodeDefinition | null {
const savedData = nodeData || {};
const registryDefinition = savedData.className ? defs[savedData.className] : null;
const registryDefinition = savedData.className ? defs[savedData.className as string] : null;
return registryDefinition || null;
}
function getSocketType(inputDef) {
function getSocketType(inputDef: InputSpec | undefined) {
if (!inputDef) return null;
const [type] = Array.isArray(inputDef) ? inputDef : [inputDef];
return Array.isArray(type) ? type[0] : type;
}
function getInputEntries(definition) {
function getInputEntries(definition: NodeDefinition | null) {
return [
...Object.entries(definition?.input?.required || {}),
...Object.entries(definition?.input?.optional || {}),
];
}
function sanitizeWidgetValues(widgetValues, definition, preservedPaths) {
function sanitizeWidgetValues(widgetValues: Record<string, unknown> | undefined, definition: NodeDefinition | null, preservedPaths: Set<unknown> | undefined) {
const nextValues = { ...(widgetValues || {}) };
getInputEntries(definition).forEach(([inputName, inputDef]) => {
getInputEntries(definition).forEach(([inputName, inputDef]: [string, InputSpec]) => {
const type = getSocketType(inputDef);
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
if (preservedPaths && preservedPaths.has(nextValues[inputName])) return;
@@ -34,7 +35,7 @@ function sanitizeWidgetValues(widgetValues, definition, preservedPaths) {
return nextValues;
}
export function hydrateWorkflowState(data, defs = {}, { preservedPaths } = {}) {
export function hydrateWorkflowState(data: SerializedWorkflow | null | undefined, defs: NodeDefsRegistry = {}, { preservedPaths }: { preservedPaths?: Set<unknown> } = {}) {
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];

View File

@@ -6,12 +6,13 @@
*/
import * as api from './api.ts';
import type { SerializedWorkflow, NodeDefsRegistry, InputSpec } from './types.ts';
const MAX_PACKED_BYTES = 100 * 1024 * 1024; // 100 MB
// ── Helpers ──────────────────────────────────────────────────────────
function arrayBufferToBase64(buffer) {
function arrayBufferToBase64(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
@@ -20,7 +21,7 @@ function arrayBufferToBase64(buffer) {
return btoa(binary);
}
function base64ToUint8Array(b64) {
function base64ToUint8Array(b64: string) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
@@ -29,21 +30,21 @@ function base64ToUint8Array(b64) {
return bytes;
}
function getInputType(spec) {
function getInputType(spec: InputSpec | null) {
if (!spec) return null;
const type = Array.isArray(spec) ? spec[0] : spec;
return Array.isArray(type) ? type[0] : type;
}
function filenameFromPath(path) {
return String(path).split('/').pop();
function filenameFromPath(path: string): string {
return String(path).split('/').pop() || path;
}
/**
* Extract the relative path portion from a session:// URI.
* e.g. "session://uploads/myfolder/scan.ibw" → "myfolder/scan.ibw"
*/
function sessionRelativePath(path) {
function sessionRelativePath(path: string) {
const prefix = 'session://uploads/';
if (path.startsWith(prefix)) return path.slice(prefix.length);
return filenameFromPath(path);
@@ -59,9 +60,9 @@ function sessionRelativePath(path) {
* @param {function} [onProgress] - Optional (packed, total) callback
* @returns {object} workflowData with packedFiles added
*/
export async function packWorkflow(workflowData, nodeDefs, onProgress) {
export async function packWorkflow(workflowData: SerializedWorkflow, nodeDefs: NodeDefsRegistry, onProgress?: (packed: number, total: number) => void) {
// 1. Collect FILE_PICKER paths only (skip FOLDER_PICKER)
const filePaths = new Set();
const filePaths = new Set<string>();
for (const node of workflowData.nodes) {
const className = node.data?.className;
@@ -87,7 +88,7 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
}
// 3. Fetch each file and encode
const packedFiles = {};
const packedFiles: Record<string, { filename: string; data: string }> = {};
let totalBytes = 0;
let packed = 0;
const total = filePaths.size;
@@ -108,7 +109,7 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
data: arrayBufferToBase64(buffer),
};
} catch (err) {
if (err.message.includes('limit')) throw err;
if ((err as Error).message.includes('limit')) throw err;
// File may not exist (e.g. cleared path) — skip
}
packed++;
@@ -134,14 +135,14 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
* @param {object} workflowData - Workflow data potentially containing packedFiles
* @returns {{ workflow: object, restoredPaths: Set<string> }}
*/
export async function unpackWorkflow(workflowData) {
export async function unpackWorkflow(workflowData: SerializedWorkflow) {
const packedFiles = workflowData.packedFiles;
if (!packedFiles || Object.keys(packedFiles).length === 0) {
return { workflow: workflowData, restoredPaths: new Set() };
}
const pathMap = {}; // oldPath → newSessionPath
const restoredPaths = new Set();
const pathMap: Record<string, string> = {}; // oldPath → newSessionPath
const restoredPaths = new Set<string>();
// 1. Upload each packed file
for (const [origPath, entry] of Object.entries(packedFiles)) {

View File

@@ -1,13 +1,14 @@
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.ts';
import type { TonoNode, TonoEdge, SerializedWorkflow } from './types';
export function serializeWorkflowState(nodes, edges) {
const compactObject = (value) => {
export function serializeWorkflowState(nodes: TonoNode[], edges: TonoEdge[]) {
const compactObject = (value: Record<string, unknown> | null | undefined) => {
if (!value || typeof value !== 'object') return null;
const entries = Object.entries(value);
return entries.length > 0 ? Object.fromEntries(entries) : null;
};
const getExtraData = (data) => compactObject(Object.fromEntries(
Object.entries(data || {}).filter(([key]) => ![
const getExtraData = (data: Record<string, unknown>) => compactObject(Object.fromEntries(
Object.entries(data || {}).filter(([key]: [string, unknown]) => ![
'label',
'className',
'widgetValues',
@@ -22,11 +23,11 @@ export function serializeWorkflowState(nodes, edges) {
'warning',
].includes(key))
));
const getRuntimeValues = (node) => compactObject(
const getRuntimeValues = (node: TonoNode) => compactObject(
sanitizeRuntimeValuesForPersistence(node.data?.className, node.data?.runtimeValues),
);
const snapDim = (v) => {
const snapDim = (v: unknown) => {
const n = Math.round(Number(v));
return Number.isFinite(n) && n > 0 ? n : undefined;
};