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, round3,
} from './angleMeasureGeometry'; } from './angleMeasureGeometry';
function clamp01(value) { function clamp01(value: number) {
return Math.max(0, Math.min(1, Number(value) || 0)); 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; if (typeof value !== 'string') return fallback;
const text = value.trim(); const text = value.trim();
return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback; return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback;
} }
function hexToRgb(value) { function hexToRgb(value: string) {
const color = sanitizeHexColor(value); const color = sanitizeHexColor(value);
return { return {
r: parseInt(color.slice(1, 3), 16), 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 alpha = Math.max(0, Math.min(1, Number(weight) || 0));
const base = hexToRgb(baseColor); const base = hexToRgb(baseColor);
const target = hexToRgb(mixWith); const target = hexToRgb(mixWith);
@@ -39,13 +39,13 @@ function mixColor(baseColor, mixWith, weight) {
return `rgb(${r}, ${g}, ${b})`; return `rgb(${r}, ${g}, ${b})`;
} }
function formatAngle(value) { function formatAngle(value: number) {
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return '0.0 deg'; if (!Number.isFinite(numeric)) return '0.0 deg';
return `${numeric.toFixed(1)} 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 va = { x: x1 - xm, y: y1 - ym };
const vb = { x: x2 - xm, y: y2 - ym }; const vb = { x: x2 - xm, y: y2 - ym };
const lenA = Math.hypot(va.x, va.y); const lenA = Math.hypot(va.x, va.y);
@@ -63,6 +63,29 @@ function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
].join(' '); ].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({ export default function AngleMeasureOverlay({
image, image,
x1, x1,
@@ -78,9 +101,9 @@ export default function AngleMeasureOverlay({
strokeWidth, strokeWidth,
nodeId, nodeId,
onWidgetChange, onWidgetChange,
}) { }: AngleMeasureOverlayProps) {
const containerRef = useRef(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(null); const [dragging, setDragging] = useState<AngleDragState | null>(null);
const resolvedColor = sanitizeHexColor(color, '#ff9800'); const resolvedColor = sanitizeHexColor(color, '#ff9800');
const resolvedStrokeWidth = Math.max(0.35, Math.min(6, Number(strokeWidth) || 1.35)); const resolvedStrokeWidth = Math.max(0.35, Math.min(6, Number(strokeWidth) || 1.35));
const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42); const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42);
@@ -88,21 +111,21 @@ export default function AngleMeasureOverlay({
const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72); const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72);
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32); const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
const getCoords = useCallback((event) => { const getCoords = useCallback((event: React.PointerEvent<Element>) => {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current!.getBoundingClientRect();
return { return {
fx: clamp01((event.clientX - rect.left) / rect.width), fx: clamp01((event.clientX - rect.left) / rect.width),
fy: clamp01((event.clientY - rect.top) / rect.height), 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]) => { Object.entries(updates).forEach(([name, value]) => {
onWidgetChange(nodeId, name, value); onWidgetChange(nodeId, name, value);
}); });
}, [nodeId, onWidgetChange]); }, [nodeId, onWidgetChange]);
const onPointerDown = useCallback((handle) => (event) => { const onPointerDown = useCallback((handle: string) => (event: React.PointerEvent<Element>) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
@@ -120,15 +143,15 @@ export default function AngleMeasureOverlay({
setDragging({ handle }); setDragging({ handle });
}, [getCoords, x1, y1, xm, ym, x2, y2]); }, [getCoords, x1, y1, xm, ym, x2, y2]);
const onPointerMove = useCallback((event) => { const onPointerMove = useCallback((event: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return; if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(event); const { fx, fy } = getCoords(event);
if (dragging.handle === 'mid') { if (dragging.handle === 'mid') {
updateWidgets(moveAngleWidget( updateWidgets(moveAngleWidget(
dragging.points, dragging.points!,
fx - dragging.start.fx, fx - dragging.start!.fx,
fy - dragging.start.fy, fy - dragging.start!.fy,
)); ));
return; return;
} }
@@ -168,7 +191,7 @@ export default function AngleMeasureOverlay({
'--angle-badge-text-color': resolvedBadgeTextColor, '--angle-badge-text-color': resolvedBadgeTextColor,
'--angle-badge-border-color': resolvedBadgeBorderColor, '--angle-badge-border-color': resolvedBadgeBorderColor,
'--angle-stroke-width': `${resolvedStrokeWidth}`, '--angle-stroke-width': `${resolvedStrokeWidth}`,
}} } as React.CSSProperties}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onLostPointerCapture={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'; 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({ export default function CropBoxOverlay({
image, x1, y1, x2, y2, image, x1, y1, x2, y2,
aLocked, bLocked, aLocked, bLocked,
nodeId, onWidgetChange, nodeId, onWidgetChange,
}) { }: CropBoxOverlayProps) {
const containerRef = useRef(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(null); const [dragging, setDragging] = useState<string | null>(null);
const getCoords = useCallback((e) => { const getCoords = useCallback((e: React.PointerEvent<Element>) => {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current!.getBoundingClientRect();
return { return {
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)), 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)), 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 === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return; if (point === 'p2' && bLocked) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
e.target.setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(point); setDragging(point);
}, [aLocked, bLocked]); }, [aLocked, bLocked]);
const onPointerMove = useCallback((e) => { const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return; if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e); const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3)); 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), * Marker positions are driven by widget values (immediate React state),
* not by backend overlay coords, so they move instantly during drag. * 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({ export default function CrossSectionOverlay({
image, x1, y1, x2, y2, image, x1, y1, x2, y2,
aLocked, bLocked, aLocked, bLocked,
nodeId, onWidgetChange, nodeId, onWidgetChange,
showLine = true, showLine = true,
}) { }: CrossSectionOverlayProps) {
const containerRef = useRef(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(null); // 'p1' or 'p2' const [dragging, setDragging] = useState<string | null>(null); // 'p1' or 'p2'
const getCoords = useCallback((e) => { const getCoords = useCallback((e: React.PointerEvent<Element>) => {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current!.getBoundingClientRect();
return { return {
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)), 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)), 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 === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return; if (point === 'p2' && bLocked) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
e.target.setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(point); setDragging(point);
}, [aLocked, bLocked]); }, [aLocked, bLocked]);
const onPointerMove = useCallback((e) => { const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return; if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e); const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3)); 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 ReactDOM from 'react-dom';
import { marked } from 'marked'; 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 ────────────────────────────── // ── Parse headings from markdown source ──────────────────────────────
function parseHeadings(md) { function parseHeadings(md: string): Heading[] {
if (!md) return []; if (!md) return [];
const headings = []; const headings: Heading[] = [];
const lines = md.split('\n'); const lines = md.split('\n');
for (const line of lines) { for (const line of lines) {
const m = line.match(/^(#{1,6})\s+(.+)/); const m = line.match(/^(#{1,6})\s+(.+)/);
if (m) { if (m) {
const text = m[2].replace(/[*_`~[\]]/g, '').trim(); const text = m[2].replace(/[*_`~[\]]/g, '').trim();
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, ''); 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; return headings;
@@ -21,9 +34,9 @@ function parseHeadings(md) {
// ── Inject id attributes into rendered HTML headings ───────────────── // ── Inject id attributes into rendered HTML headings ─────────────────
function injectHeadingIds(html, headings) { function injectHeadingIds(html: string, headings: Heading[]) {
let idx = 0; 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) { if (idx < headings.length) {
return `<${tag} id="${headings[idx++].id}">`; return `<${tag} id="${headings[idx++].id}">`;
} }
@@ -33,9 +46,9 @@ function injectHeadingIds(html, headings) {
// ── Build a tree from flat heading list ────────────────────────────── // ── Build a tree from flat heading list ──────────────────────────────
function buildTocTree(headings) { function buildTocTree(headings: Heading[]): Heading[] {
const root = { children: [] }; const root: { children: Heading[] } = { children: [] };
const stack = [{ node: root, level: 0 }]; const stack: { node: { children: Heading[] }; level: number }[] = [{ node: root, level: 0 }];
for (const h of headings) { for (const h of headings) {
const item = { ...h, children: [] }; const item = { ...h, children: [] };
while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop(); while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop();
@@ -47,7 +60,14 @@ function buildTocTree(headings) {
// ── TOC sidebar component ──────────────────────────────────────────── // ── 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 hasChildren = item.children.length > 0;
const isCollapsed = collapsed[item.id]; const isCollapsed = collapsed[item.id];
return ( return (
@@ -82,13 +102,18 @@ function TocItem({ item, collapsed, onToggle, onNavigate }) {
); );
} }
function Toc({ headings, contentRef }) { interface TocProps {
const [collapsed, setCollapsed] = useState({}); 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 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)}`); const el = contentRef.current?.querySelector(`#${CSS.escape(id)}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}; };
@@ -108,14 +133,14 @@ function Toc({ headings, contentRef }) {
// ── Click handler for .md links ────────────────────────────────────── // ── Click handler for .md links ──────────────────────────────────────
function useMdLinkHandler(onOpenDoc) { function useMdLinkHandler(onOpenDoc: (filename: string) => void) {
return (e) => { return (e: React.MouseEvent<HTMLElement>) => {
const a = e.target.closest('a[href]'); const a = (e.target as HTMLElement).closest('a[href]');
if (!a) return; if (!a) return;
const href = a.getAttribute('href'); const href = a.getAttribute('href');
if (href && /\.md$/i.test(href) && !href.startsWith('http')) { if (href && /\.md$/i.test(href) && !href.startsWith('http')) {
e.preventDefault(); e.preventDefault();
const filename = href.split('/').pop(); const filename = href.split('/').pop() ?? href;
onOpenDoc(filename); onOpenDoc(filename);
} }
}; };
@@ -123,14 +148,19 @@ function useMdLinkHandler(onOpenDoc) {
// ── Content pane with TOC ──────────────────────────────────────────── // ── Content pane with TOC ────────────────────────────────────────────
function HelpContent({ content, onOpenDoc }) { interface HelpContentProps {
const contentRef = useRef(null); content: string;
onOpenDoc: (filename: string) => void;
}
function HelpContent({ content, onOpenDoc }: HelpContentProps) {
const contentRef = useRef<HTMLDivElement>(null);
const handleClick = useMdLinkHandler(onOpenDoc); const handleClick = useMdLinkHandler(onOpenDoc);
const md = content || '*Loading…*'; const md = content || '*Loading…*';
const headings = useMemo(() => parseHeadings(md), [md]); const headings = useMemo(() => parseHeadings(md), [md]);
const html = useMemo(() => { const html = useMemo(() => {
let rendered; let rendered: string;
try { rendered = marked.parse(md); } catch { rendered = md; } try { rendered = marked.parse(md) as string; } catch { rendered = md; }
return injectHeadingIds(rendered, headings); return injectHeadingIds(rendered, headings);
}, [md, headings]); }, [md, headings]);
@@ -150,16 +180,22 @@ function HelpContent({ content, onOpenDoc }) {
// ── Journal tab ────────────────────────────────────────────────────── // ── 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 [isEditing, setIsEditing] = useState(false);
const contentRef = useRef(null); const contentRef = useRef<HTMLDivElement>(null);
const handleClick = useMdLinkHandler(onOpenDoc); const handleClick = useMdLinkHandler(onOpenDoc);
let renderedHtml = ''; let renderedHtml = '';
let headings = []; let headings: Heading[] = [];
if (!isEditing && content?.trim()) { if (!isEditing && content?.trim()) {
headings = parseHeadings(content); 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 ( return (
@@ -215,11 +251,21 @@ function JournalTab({ content, onChange, onOpenDoc }) {
// ── Main panel manager ─────────────────────────────────────────────── // ── 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); const [collapsed, setCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeTab) onTabClose(activeTab); if (e.key === 'Escape' && activeTab) onTabClose(activeTab);
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,11 @@ import React, { useContext, useRef, useState, useEffect, useCallback, useMemo }
import { NodeResizeControl, useStore } from '@xyflow/react'; import { NodeResizeControl, useStore } from '@xyflow/react';
import { marked } from 'marked'; import { marked } from 'marked';
import { NodeContext } from './CustomNode'; import { NodeContext } from './CustomNode';
import type { NodeContextValue } from './types';
marked.use({ breaks: true, gfm: true }); 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' }, default: { bg: '#1e293b', border: '#334155', dot: '#475569' },
blue: { bg: '#0c1f3d', border: '#1d4ed8', dot: '#3b82f6' }, blue: { bg: '#0c1f3d', border: '#1d4ed8', dot: '#3b82f6' },
green: { bg: '#062016', border: '#15803d', dot: '#22c55e' }, green: { bg: '#062016', border: '#15803d', dot: '#22c55e' },
@@ -14,16 +15,21 @@ const NOTE_COLORS = {
purple: { bg: '#160c2a', border: '#7c3aed', dot: '#a855f7' }, purple: { bg: '#160c2a', border: '#7c3aed', dot: '#a855f7' },
}; };
function TextNoteNode({ id, data }) { interface TextNoteNodeProps {
const ctx = useContext(NodeContext); 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 [isEditing, setIsEditing] = useState(false);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const textareaRef = useRef(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const selected = useStore( const selected = useStore(
useCallback( useCallback(
(s) => { (s: any) => {
const node = s.nodeLookup?.get(id) || s.nodes?.find((n) => n.id === id); const node = s.nodeLookup?.get(id) || s.nodes?.find((n: any) => n.id === id);
return !!node?.selected; return !!node?.selected;
}, },
[id], [id],
@@ -35,7 +41,7 @@ function TextNoteNode({ id, data }) {
const palette = NOTE_COLORS[color] ?? NOTE_COLORS.default; const palette = NOTE_COLORS[color] ?? NOTE_COLORS.default;
const setField = useCallback( const setField = useCallback(
(name, value) => ctx?.onWidgetChange?.(id, name, value), (name: string, value: unknown) => ctx?.onWidgetChange?.(id, name, value),
[ctx, id], [ctx, id],
); );
@@ -45,14 +51,14 @@ function TextNoteNode({ id, data }) {
} }
}, [isEditing]); }, [isEditing]);
const onDoubleClick = useCallback((e) => { const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsEditing(true); setIsEditing(true);
}, []); }, []);
const onBlur = useCallback(() => setIsEditing(false), []); const onBlur = useCallback(() => setIsEditing(false), []);
const onKeyDown = useCallback((e) => { const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl/Cmd+Enter or Escape finishes editing // Ctrl/Cmd+Enter or Escape finishes editing
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
e.preventDefault(); e.preventDefault();
@@ -62,6 +68,7 @@ function TextNoteNode({ id, data }) {
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const ta = textareaRef.current; const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart; const start = ta.selectionStart;
const end = ta.selectionEnd; const end = ta.selectionEnd;
const next = text.substring(0, start) + ' ' + text.substring(end); 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_LOCKED_COLOR = '#e91e63';
const MARKER_LABEL_FILL = '#0f172a'; const MARKER_LABEL_FILL = '#0f172a';
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); }
function round4(v) { return parseFloat(v.toFixed(4)); } function round4(v: number) { return parseFloat(v.toFixed(4)); }
function trimZeros(t) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); } function trimZeros(t: string) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
function formatTick(value) { function formatTick(value: number) {
const abs = Math.abs(value); const abs = Math.abs(value);
if (abs === 0) return '0'; if (abs === 0) return '0';
if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e'); 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)); 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]; 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]; if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax];
let min = Infinity, max = -Infinity; let min = Infinity, max = -Infinity;
for (const v of values) { if (Number.isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } } 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]; return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax];
} }
export default function ThresholdHistogram({ overlay, threshold, thresholdConnected, nodeId, onWidgetChange }) { interface ThresholdHistogramProps {
const containerRef = useRef(null); 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 [dragging, setDragging] = useState(false);
const [size, setSize] = useState({ width: 0 }); const [size, setSize] = useState({ width: 0 });
@@ -63,9 +71,9 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
return () => window.removeEventListener('resize', update); return () => window.removeEventListener('resize', update);
}, []); }, []);
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) || []; ? overlay.x_axis : overlay?.line?.map((_: unknown, i: number) => i) || [];
const yValues = Array.isArray(overlay?.line) ? overlay.line : []; const yValues: number[] = Array.isArray(overlay?.line) ? overlay.line : [];
const method = overlay?.method ?? 'absolute'; const method = overlay?.method ?? 'absolute';
const locked = (overlay?.locked ?? false) || !!thresholdConnected; const locked = (overlay?.locked ?? false) || !!thresholdConnected;
const xMin = overlay?.x_min ?? 0; const xMin = overlay?.x_min ?? 0;
@@ -84,12 +92,12 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
const yMin = yMinRaw - yPad; const yMin = yMinRaw - yPad;
const yMax = yMaxRaw + yPad; const yMax = yMaxRaw + yPad;
const scaleX = useCallback((v) => { const scaleX = useCallback((v: number) => {
if (xExtMax === xExtMin) return plotLeft + plotWidth / 2; if (xExtMax === xExtMin) return plotLeft + plotWidth / 2;
return plotLeft + (v - xExtMin) / (xExtMax - xExtMin) * plotWidth; return plotLeft + (v - xExtMin) / (xExtMax - xExtMin) * plotWidth;
}, [plotLeft, plotWidth, xExtMin, xExtMax]); }, [plotLeft, plotWidth, xExtMin, xExtMax]);
const scaleY = useCallback((v) => { const scaleY = useCallback((v: number) => {
if (yMax === yMin) return plotTop + plotHeight / 2; if (yMax === yMin) return plotTop + plotHeight / 2;
return plotTop + (1 - (v - yMin) / (yMax - yMin)) * plotHeight; return plotTop + (1 - (v - yMin) / (yMax - yMin)) * plotHeight;
}, [plotTop, plotHeight, yMin, yMax]); }, [plotTop, plotHeight, yMin, yMax]);
@@ -116,7 +124,7 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
return scaleY(yValues[best]); return scaleY(yValues[best]);
})(); })();
const handleDrag = useCallback((e) => { const handleDrag = useCallback((e: React.PointerEvent<Element>) => {
if (!onWidgetChange || !nodeId || locked || !containerRef.current) return; if (!onWidgetChange || !nodeId || locked || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const frac = clamp((e.clientX - rect.left - plotLeft) / plotWidth, 0, 1); 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, 'threshold', newThreshold);
}, [onWidgetChange, nodeId, locked, plotLeft, plotWidth, method, xMin, xMax]); }, [onWidgetChange, nodeId, locked, plotLeft, plotWidth, method, xMin, xMax]);
const onPointerDown = useCallback((e) => { const onPointerDown = useCallback((e: React.PointerEvent<Element>) => {
if (locked) return; if (locked) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -136,13 +144,13 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
setDragging(true); setDragging(true);
}, [locked]); }, [locked]);
const onPointerMove = useCallback((e) => { const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (dragging) handleDrag(e); if (dragging) handleDrag(e);
}, [dragging, handleDrag]); }, [dragging, handleDrag]);
const onPointerUp = useCallback(() => setDragging(false), []); 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 xTickCount = Math.max(2, Math.min(5, Math.floor(plotWidth / 70)));
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40))); const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
const xTicks = makeTicks(xExtMin, xExtMax, xTickCount); 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)); 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)); 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 va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) }; const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) };
const lenA = Math.hypot(va.x, va.y); 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); const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
return { return {
x: clamp01(base.x + (Number(labelDx) || 0)), 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 nextDx = Number(dx) || 0;
const nextDy = Number(dy) || 0; const nextDy = Number(dy) || 0;
const xs = [points.x1, points.xm, points.x2]; 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 ax = Number(x1) - Number(xm);
const ay = Number(y1) - Number(ym); const ay = Number(y1) - Number(ym);
const bx = Number(x2) - Number(xm); const bx = Number(x2) - Number(xm);

View File

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

View File

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

View File

@@ -1,11 +1,20 @@
import { extractWorkflow } from './pngMetadata.ts'; 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.json', type: 'json' },
{ path: '/default-workflow.png', type: 'png' }, { 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; let response;
try { try {
response = await fetchImpl(candidate.path, { cache: 'no-store' }); response = await fetchImpl(candidate.path, { cache: 'no-store' });
@@ -39,9 +48,9 @@ async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
} }
export async function loadDefaultWorkflowAsset({ export async function loadDefaultWorkflowAsset({
fetchImpl = fetch, fetchImpl = fetch as FetchImpl,
extractWorkflowFn = extractWorkflow, extractWorkflowFn = extractWorkflow as ExtractWorkflowFn,
} = {}) { }: { fetchImpl?: FetchImpl; extractWorkflowFn?: ExtractWorkflowFn } = {}) {
for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) { for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) {
const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn); const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn);
if (workflow) { if (workflow) {

View File

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

View File

@@ -3,4 +3,4 @@ import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import './styles.css'; 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_DEFAULT_COLOR = '#ff0000';
export const MARKUP_PREVIEW_REFERENCE_DIM = 512; 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); const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0; if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, numeric)); 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; if (typeof color !== 'string') return fallback;
const value = color.trim(); const value = color.trim();
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback; return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
} }
export function sanitizeMarkupShape( export function sanitizeMarkupShape(
shape, shape: Partial<MarkupShape> | null | undefined,
fallbackShape = MARKUP_DEFAULT_SHAPE, fallbackShape: string = MARKUP_DEFAULT_SHAPE,
fallbackColor = MARKUP_DEFAULT_COLOR, fallbackColor: string = MARKUP_DEFAULT_COLOR,
fallbackWidth = 3, fallbackWidth: number = 3,
) { ): MarkupShape | null {
if (!shape || typeof shape !== 'object') return 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 x1 = clampFraction(shape.x1);
const y1 = clampFraction(shape.y1); const y1 = clampFraction(shape.y1);
const x2 = clampFraction(shape.x2); const x2 = clampFraction(shape.x2);
@@ -39,15 +49,15 @@ export function sanitizeMarkupShape(
} }
export function parseMarkupShapes( export function parseMarkupShapes(
markupShapes, markupShapes: unknown,
fallbackShape = MARKUP_DEFAULT_SHAPE, fallbackShape: string = MARKUP_DEFAULT_SHAPE,
fallbackColor = MARKUP_DEFAULT_COLOR, fallbackColor: string = MARKUP_DEFAULT_COLOR,
fallbackWidth = 3, fallbackWidth: number = 3,
) { ): MarkupShape[] {
if (Array.isArray(markupShapes)) { if (Array.isArray(markupShapes)) {
return markupShapes return markupShapes
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth)) .map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean); .filter((s): s is MarkupShape => s != null);
} }
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return []; if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
@@ -57,13 +67,13 @@ export function parseMarkupShapes(
if (!Array.isArray(parsed)) return []; if (!Array.isArray(parsed)) return [];
return parsed return parsed
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth)) .map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean); .filter((s): s is MarkupShape => s != null);
} catch { } catch {
return []; return [];
} }
} }
export function getArrowGeometry(shape, imageWidth, imageHeight) { export function getArrowGeometry(shape: MarkupShape, imageWidth: number, imageHeight: number) {
const x1 = shape.x1 * imageWidth; const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight; const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth; 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 normalizedWidth = Math.max(1, Math.round(Number(width) || 1));
const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0); const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0);
const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM); const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM);

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts'; 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); const [type, opts] = getSpecTypeAndOptions(spec);
if (isDataSocketSpec(spec)) return undefined; if (isDataSocketSpec(spec)) return undefined;
if (type === 'BUTTON') return undefined; if (type === 'BUTTON') return undefined;
@@ -13,8 +14,8 @@ export function getDefaultWidgetValue(spec) {
return opts?.default ?? ''; return opts?.default ?? '';
} }
export function buildDefaultWidgetValues(definition) { export function buildDefaultWidgetValues(definition: NodeDefinition | null | undefined) {
const widgetValues = {}; const widgetValues: Record<string, unknown> = {};
const required = definition?.input?.required || {}; const required = definition?.input?.required || {};
for (const [name, spec] of Object.entries(required)) { for (const [name, spec] of Object.entries(required)) {
const value = getDefaultWidgetValue(spec); 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 { export function formatUiLabel(text: unknown): string {
return String(text ?? '') return String(text ?? '')
.replace(/_/g, ' ') .replace(/_/g, ' ')
@@ -13,7 +21,7 @@ function normalizeInputNames(raw: unknown): string[] {
.filter((value) => value.length > 0); .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]; const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0];
if (explicitInputName && dataInputByName?.has(explicitInputName)) { if (explicitInputName && dataInputByName?.has(explicitInputName)) {
return explicitInputName; return explicitInputName;
@@ -34,8 +42,8 @@ export function getWidgetCombinedInputName(widget, dataInputByName) {
return null; return null;
} }
export function buildCombinedInputNameByWidgetName(widgets, dataInputs) { export function buildCombinedInputNameByWidgetName(widgets: WidgetDescriptor[], dataInputs: DataInputDescriptor[]) {
const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input])); const dataInputByName = new Map((dataInputs || []).map((input: DataInputDescriptor) => [input.name, input]));
const combinedInputNameByWidgetName = new Map(); const combinedInputNameByWidgetName = new Map();
for (const widget of widgets || []) { for (const widget of widgets || []) {

View File

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

View File

@@ -19,6 +19,7 @@ export interface InputOptions {
text_input?: boolean; text_input?: boolean;
color_picker?: boolean; color_picker?: boolean;
colormap_stops?: boolean; colormap_stops?: boolean;
top_socket_input?: string | string[];
set_widgets?: Record<string, unknown>; set_widgets?: Record<string, unknown>;
show_when_source_type?: Record<string, string[]>; show_when_source_type?: Record<string, string[]>;
show_when_widget_value?: Record<string, unknown[]>; show_when_widget_value?: Record<string, unknown[]>;
@@ -45,6 +46,7 @@ export interface NodeDefinition {
output: string[]; output: string[];
output_name: string[]; output_name: string[];
output_paths?: string[]; output_paths?: string[];
output_accepted_types?: string[][];
category: string; category: string;
manual_trigger?: boolean; 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. * Snapshot-based undo/redo for nodes + edges.
@@ -7,10 +14,10 @@ import { useRef, useCallback } from 'react';
* Call `undo` / `redo` to restore. * Call `undo` / `redo` to restore.
*/ */
export default function useUndoRedo({ maxHistory = 50 } = {}) { export default function useUndoRedo({ maxHistory = 50 } = {}) {
const pastRef = useRef([]); const pastRef = useRef<Snapshot[]>([]);
const futureRef = useRef([]); const futureRef = useRef<Snapshot[]>([]);
const pushSnapshot = useCallback((nodes, edges, nextId) => { const pushSnapshot = useCallback((nodes: TonoNode[], edges: TonoEdge[], nextId: number) => {
pastRef.current = [ pastRef.current = [
...pastRef.current.slice(-(maxHistory - 1)), ...pastRef.current.slice(-(maxHistory - 1)),
{ {
@@ -22,7 +29,7 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
futureRef.current = []; futureRef.current = [];
}, [maxHistory]); }, [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; if (pastRef.current.length === 0) return false;
futureRef.current = [ futureRef.current = [
...futureRef.current, ...futureRef.current,
@@ -32,14 +39,14 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
nextId: nextIdRef.current, nextId: nextIdRef.current,
}, },
]; ];
const snapshot = pastRef.current.pop(); const snapshot = pastRef.current.pop()!;
setNodes(snapshot.nodes); setNodes(snapshot.nodes);
setEdges(snapshot.edges); setEdges(snapshot.edges);
nextIdRef.current = snapshot.nextId; nextIdRef.current = snapshot.nextId;
return true; 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; if (futureRef.current.length === 0) return false;
pastRef.current = [ pastRef.current = [
...pastRef.current, ...pastRef.current,
@@ -49,7 +56,7 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
nextId: nextIdRef.current, nextId: nextIdRef.current,
}, },
]; ];
const snapshot = futureRef.current.pop(); const snapshot = futureRef.current.pop()!;
setNodes(snapshot.nodes); setNodes(snapshot.nodes);
setEdges(snapshot.edges); setEdges(snapshot.edges);
nextIdRef.current = snapshot.nextId; 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, Y: 1e24, Z: 1e21, E: 1e18, P: 1e15, T: 1e12,
G: 1e9, M: 1e6, k: 1e3, G: 1e9, M: 1e6, k: 1e3,
m: 1e-3, u: 1e-6, µ: 1e-6, n: 1e-9, p: 1e-12, 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. * 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. * 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(); const s = String(text ?? '').trim();
if (!s) return { numeric: 0, unit: '' }; if (!s) return { numeric: 0, unit: '' };
@@ -60,7 +60,7 @@ const SI_PREFIXES = [
{ exp: 24, prefix: 'Y' }, { exp: 24, prefix: 'Y' },
]; ];
const SUPERSCRIPT_DIGITS = { const SUPERSCRIPT_DIGITS: Record<string, string> = {
'-': '⁻', '-': '⁻',
'0': '⁰', '0': '⁰',
'1': '¹', '1': '¹',
@@ -74,7 +74,7 @@ const SUPERSCRIPT_DIGITS = {
'9': '⁹', '9': '⁹',
}; };
export function formatNumericCell(value) { export function formatNumericCell(value: unknown) {
if (value == null) return ''; if (value == null) return '';
if (typeof value === 'number') { if (typeof value === 'number') {
if (!Number.isFinite(value)) return String(value); if (!Number.isFinite(value)) return String(value);
@@ -87,18 +87,18 @@ export function formatNumericCell(value) {
return String(value); return String(value);
} }
function toSuperscript(text) { function toSuperscript(text: string | number) {
return String(text) return String(text)
.split('') .split('')
.map((char) => SUPERSCRIPT_DIGITS[char] || char) .map((char) => SUPERSCRIPT_DIGITS[char] || char)
.join(''); .join('');
} }
export function formatDisplayUnit(unit) { export function formatDisplayUnit(unit: unknown) {
return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent)); return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent));
} }
function parsePrefixableUnit(unit) { function parsePrefixableUnit(unit: unknown) {
const text = String(unit ?? '').trim(); const text = String(unit ?? '').trim();
if (!text) return null; if (!text) return null;
@@ -113,11 +113,11 @@ function parsePrefixableUnit(unit) {
return { baseUnit: text, power: 1 }; 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)}`; 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 abs = Math.abs(value);
const candidates = SI_PREFIXES.map(({ exp, prefix }) => { const candidates = SI_PREFIXES.map(({ exp, prefix }) => {
const scaled = value / (10 ** (exp * power)); const scaled = value / (10 ** (exp * power));
@@ -147,7 +147,7 @@ function choosePrefixExponent(value, power) {
* and prefixed unit label to use for a whole axis. * and prefixed unit label to use for a whole axis.
* All tick values should be divided by `scale` before display, and `unitLabel` shown once. * 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) { if (!unit || typeof representativeValue !== 'number' || !Number.isFinite(representativeValue) || representativeValue === 0) {
return { scale: 1, unitLabel: unit || '' }; return { scale: 1, unitLabel: unit || '' };
} }
@@ -157,7 +157,7 @@ export function getAxisScale(representativeValue, unit) {
return { scale: representativeValue / scaled, unitLabel: unitText }; return { scale: representativeValue / scaled, unitLabel: unitText };
} }
export function applySIPrefix(value, unit) { export function applySIPrefix(value: unknown, unit: unknown) {
const formattedUnit = formatDisplayUnit(unit); const formattedUnit = formatDisplayUnit(unit);
if (typeof value !== 'number' || !Number.isFinite(value)) { if (typeof value !== 'number' || !Number.isFinite(value)) {
return { valueText: formatNumericCell(value), unitText: formattedUnit }; 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') { if (!row || typeof row !== 'object' || typeof column !== 'string' || column === 'unit') {
return null; return null;
} }
const unitColumn = `${column}_unit`; 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) { export function getTableColumns(rows: Array<Record<string, unknown>> | null | undefined) {
const columns = []; const columns: string[] = [];
const hiddenColumns = new Set(); const hiddenColumns = new Set<string>();
for (const row of rows || []) { for (const row of rows || []) {
if (!row || typeof row !== 'object') continue; if (!row || typeof row !== 'object') continue;
@@ -208,7 +208,7 @@ export function getTableColumns(rows) {
return columns.filter((column) => !hiddenColumns.has(column)); 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); const companionUnitColumn = getCompanionUnitColumn(column, row);
if (companionUnitColumn) { if (companionUnitColumn) {
const formatted = applySIPrefix(row?.[column], row?.[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 { toBlob } from 'html-to-image';
import { CANVAS_COLORS } from './constants.ts'; import { CANVAS_COLORS } from './constants.ts';
import { CAPTURE_SELECTOR as linePlotSelector } from './LinePlotOverlay'; // Mirror the CAPTURE_SELECTOR values from each overlay component.
import { CAPTURE_SELECTOR as thresholdSelector } from './ThresholdHistogram'; // Duplicated here so workflowCapture.ts stays a plain .ts file that
import { CAPTURE_SELECTOR as csSelector } from './CrossSectionOverlay'; // Node can run without a JSX transform (needed for tests).
import { CAPTURE_SELECTOR as cropSelector } from './CropBoxOverlay'; // To register a new overlay, add its selector string here AND export
import { CAPTURE_SELECTOR as markupSelector } from './MarkupOverlay'; // CAPTURE_SELECTOR from the overlay component file.
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.
export const OVERLAY_CAPTURE_SELECTORS = [ 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) { function encodeBase64(bytes: Uint8Array) {
if (typeof Buffer !== 'undefined') { if (typeof (globalThis as any).Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64'); return (globalThis as any).Buffer.from(bytes).toString('base64');
} }
let binary = ''; let binary = '';
@@ -26,13 +25,13 @@ function encodeBase64(bytes) {
return btoa(binary); return btoa(binary);
} }
async function blobToDataUrl(blob) { async function blobToDataUrl(blob: Blob | null) {
if (!blob) return null; if (!blob) return null;
const bytes = new Uint8Array(await blob.arrayBuffer()); const bytes = new Uint8Array(await blob.arrayBuffer());
return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`; return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`;
} }
function getElementSize(el) { function getElementSize(el: HTMLElement) {
const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 }; const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 };
return { return {
width: Math.max(1, Math.round(el.clientWidth || rect.width || 0)), 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 (img.complete && img.naturalWidth > 0) return;
if (typeof img.decode === 'function') { if (typeof img.decode === 'function') {
try { try {
@@ -50,7 +49,7 @@ export async function waitForImageElement(img) {
// Fall back to load/error listeners below. // Fall back to load/error listeners below.
} }
} }
await new Promise((resolve) => { await new Promise<void>((resolve) => {
const done = () => { const done = () => {
img.removeEventListener('load', done); img.removeEventListener('load', done);
img.removeEventListener('error', 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; const src = img.currentSrc || img.src;
if (!src) return null; if (!src) return null;
if (!src.startsWith('data:')) return src; if (!src.startsWith('data:')) return src;
@@ -85,9 +84,9 @@ export async function getCaptureImageDataUrl(img) {
} }
export function createCapturePlaceholder( export function createCapturePlaceholder(
el, el: HTMLElement,
dataUrl, dataUrl: string,
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) } = {}, { stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) }: { stretch?: boolean; documentRef?: Document; getComputedStyleFn?: typeof getComputedStyle } = {},
) { ) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const style = getComputedStyleFn(el); const style = getComputedStyleFn(el);
@@ -110,7 +109,7 @@ export function createCapturePlaceholder(
return placeholder; return placeholder;
} }
async function renderCanvasToDataUrl(canvas) { async function renderCanvasToDataUrl(canvas: HTMLCanvasElement) {
try { try {
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} catch { } 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 { width, height } = getElementSize(el);
const blob = await toBlobImpl(el, { const blob = await toBlobImpl(el, {
width, width,
@@ -133,7 +132,7 @@ async function renderElementToDataUrl(el, toBlobImpl) {
return blobToDataUrl(blob); 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 = []; const restorers = [];
for (const el of elements) { for (const el of elements) {
@@ -153,21 +152,33 @@ async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPl
return restorers; return restorers;
} }
function defaultQueryAll(root, selector) { function defaultQueryAll(root: HTMLElement, selector: string): HTMLElement[] {
return Array.from(root.querySelectorAll(selector)); return Array.from(root.querySelectorAll(selector)) as HTMLElement[];
} }
function defaultNextFrame() { 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 queryAll = deps.queryAll ?? defaultQueryAll;
const toBlobImpl = deps.toBlobImpl ?? toBlob; const toBlobImpl = deps.toBlobImpl ?? toBlob;
const waitForImage = deps.waitForImageElement ?? waitForImageElement; const waitForImage = deps.waitForImageElement ?? waitForImageElement;
const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl; const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl;
const renderCanvas = deps.renderCanvasToDataUrl ?? renderCanvasToDataUrl; 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 createPlaceholderFn = deps.createPlaceholder ?? createCapturePlaceholder;
const nextFrame = deps.nextFrame ?? defaultNextFrame; const nextFrame = deps.nextFrame ?? defaultNextFrame;
const overlaySelectors = deps.overlaySelectors ?? OVERLAY_CAPTURE_SELECTORS; 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)); await Promise.all(images.map(waitForImage));
try { try {
restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true)); restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage, createPlaceholderFn, false)); restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage as (el: HTMLElement) => Promise<string | null>, createPlaceholderFn, false));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas, createPlaceholderFn, true)); restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas as (el: HTMLElement) => Promise<string | null>, createPlaceholderFn, true));
await nextFrame(); await nextFrame();
await nextFrame(); await nextFrame();

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
id: '1', id: '1',
type: 'custom', type: 'custom',
position: { x: 100, y: 200 }, position: { x: 100, y: 200 },
width: 320,
dragHandle: '.node-header', dragHandle: '.node-header',
data: { data: {
label: 'Demo Label', label: 'Demo Label',

View File

@@ -5,7 +5,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
"strict": false, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,