finalize typescript migration
This commit is contained in:
@@ -10,17 +10,17 @@ import {
|
||||
round3,
|
||||
} from './angleMeasureGeometry';
|
||||
|
||||
function clamp01(value) {
|
||||
function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function sanitizeHexColor(value, fallback = '#ff9800') {
|
||||
function sanitizeHexColor(value: unknown, fallback = '#ff9800') {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const text = value.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function hexToRgb(value) {
|
||||
function hexToRgb(value: string) {
|
||||
const color = sanitizeHexColor(value);
|
||||
return {
|
||||
r: parseInt(color.slice(1, 3), 16),
|
||||
@@ -29,7 +29,7 @@ function hexToRgb(value) {
|
||||
};
|
||||
}
|
||||
|
||||
function mixColor(baseColor, mixWith, weight) {
|
||||
function mixColor(baseColor: string, mixWith: string, weight: number) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(weight) || 0));
|
||||
const base = hexToRgb(baseColor);
|
||||
const target = hexToRgb(mixWith);
|
||||
@@ -39,13 +39,13 @@ function mixColor(baseColor, mixWith, weight) {
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function formatAngle(value) {
|
||||
function formatAngle(value: number) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '0.0 deg';
|
||||
return `${numeric.toFixed(1)} deg`;
|
||||
}
|
||||
|
||||
function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
|
||||
function buildAngleArcPath(x1: number, y1: number, xm: number, ym: number, x2: number, y2: number) {
|
||||
const va = { x: x1 - xm, y: y1 - ym };
|
||||
const vb = { x: x2 - xm, y: y2 - ym };
|
||||
const lenA = Math.hypot(va.x, va.y);
|
||||
@@ -63,6 +63,29 @@ function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
interface AngleMeasureOverlayProps {
|
||||
image: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
xm: number;
|
||||
ym: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
labelDx: number;
|
||||
labelDy: number;
|
||||
angleDeg: number;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
interface AngleDragState {
|
||||
handle: string;
|
||||
start?: { fx: number; fy: number };
|
||||
points?: { x1: number; y1: number; xm: number; ym: number; x2: number; y2: number };
|
||||
}
|
||||
|
||||
export default function AngleMeasureOverlay({
|
||||
image,
|
||||
x1,
|
||||
@@ -78,9 +101,9 @@ export default function AngleMeasureOverlay({
|
||||
strokeWidth,
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
}: AngleMeasureOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<AngleDragState | null>(null);
|
||||
const resolvedColor = sanitizeHexColor(color, '#ff9800');
|
||||
const resolvedStrokeWidth = Math.max(0.35, Math.min(6, Number(strokeWidth) || 1.35));
|
||||
const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42);
|
||||
@@ -88,21 +111,21 @@ export default function AngleMeasureOverlay({
|
||||
const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72);
|
||||
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
|
||||
|
||||
const getCoords = useCallback((event) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const getCoords = useCallback((event: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return {
|
||||
fx: clamp01((event.clientX - rect.left) / rect.width),
|
||||
fy: clamp01((event.clientY - rect.top) / rect.height),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateWidgets = useCallback((updates) => {
|
||||
const updateWidgets = useCallback((updates: Record<string, unknown>) => {
|
||||
Object.entries(updates).forEach(([name, value]) => {
|
||||
onWidgetChange(nodeId, name, value);
|
||||
});
|
||||
}, [nodeId, onWidgetChange]);
|
||||
|
||||
const onPointerDown = useCallback((handle) => (event) => {
|
||||
const onPointerDown = useCallback((handle: string) => (event: React.PointerEvent<Element>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
@@ -120,15 +143,15 @@ export default function AngleMeasureOverlay({
|
||||
setDragging({ handle });
|
||||
}, [getCoords, x1, y1, xm, ym, x2, y2]);
|
||||
|
||||
const onPointerMove = useCallback((event) => {
|
||||
const onPointerMove = useCallback((event: React.PointerEvent<Element>) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(event);
|
||||
|
||||
if (dragging.handle === 'mid') {
|
||||
updateWidgets(moveAngleWidget(
|
||||
dragging.points,
|
||||
fx - dragging.start.fx,
|
||||
fy - dragging.start.fy,
|
||||
dragging.points!,
|
||||
fx - dragging.start!.fx,
|
||||
fy - dragging.start!.fy,
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -168,7 +191,7 @@ export default function AngleMeasureOverlay({
|
||||
'--angle-badge-text-color': resolvedBadgeTextColor,
|
||||
'--angle-badge-border-color': resolvedBadgeBorderColor,
|
||||
'--angle-stroke-width': `${resolvedStrokeWidth}`,
|
||||
}}
|
||||
} as React.CSSProperties}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onLostPointerCapture={onPointerUp}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,32 +2,44 @@ import React, { useRef, useState, useCallback } from 'react';
|
||||
|
||||
export const CAPTURE_SELECTOR = '.crop-overlay';
|
||||
|
||||
interface CropBoxOverlayProps {
|
||||
image: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
aLocked: boolean;
|
||||
bLocked: boolean;
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export default function CropBoxOverlay({
|
||||
image, x1, y1, x2, y2,
|
||||
aLocked, bLocked,
|
||||
nodeId, onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
}: CropBoxOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
|
||||
const getCoords = useCallback((e) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return {
|
||||
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((point) => (e) => {
|
||||
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||
if (point === 'p1' && aLocked) return;
|
||||
if (point === 'p2' && bLocked) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
e.target.setPointerCapture(e.pointerId);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDragging(point);
|
||||
}, [aLocked, bLocked]);
|
||||
|
||||
const onPointerMove = useCallback((e) => {
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(e);
|
||||
const vx = parseFloat(fx.toFixed(3));
|
||||
|
||||
@@ -10,33 +10,47 @@ export const CAPTURE_SELECTOR = '.cs-overlay';
|
||||
* Marker positions are driven by widget values (immediate React state),
|
||||
* not by backend overlay coords, so they move instantly during drag.
|
||||
*/
|
||||
|
||||
interface CrossSectionOverlayProps {
|
||||
image: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
aLocked: boolean;
|
||||
bLocked: boolean;
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
showLine?: boolean;
|
||||
}
|
||||
|
||||
export default function CrossSectionOverlay({
|
||||
image, x1, y1, x2, y2,
|
||||
aLocked, bLocked,
|
||||
nodeId, onWidgetChange,
|
||||
showLine = true,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null); // 'p1' or 'p2'
|
||||
}: CrossSectionOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<string | null>(null); // 'p1' or 'p2'
|
||||
|
||||
const getCoords = useCallback((e) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return {
|
||||
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((point) => (e) => {
|
||||
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||
if (point === 'p1' && aLocked) return;
|
||||
if (point === 'p2' && bLocked) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
e.target.setPointerCapture(e.pointerId);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDragging(point);
|
||||
}, [aLocked, bLocked]);
|
||||
|
||||
const onPointerMove = useCallback((e) => {
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(e);
|
||||
const vx = parseFloat(fx.toFixed(3));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,31 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { marked } from 'marked';
|
||||
|
||||
interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
children: Heading[];
|
||||
}
|
||||
|
||||
interface HelpTab {
|
||||
label: string;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ── Parse headings from markdown source ──────────────────────────────
|
||||
|
||||
function parseHeadings(md) {
|
||||
function parseHeadings(md: string): Heading[] {
|
||||
if (!md) return [];
|
||||
const headings = [];
|
||||
const headings: Heading[] = [];
|
||||
const lines = md.split('\n');
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(#{1,6})\s+(.+)/);
|
||||
if (m) {
|
||||
const text = m[2].replace(/[*_`~[\]]/g, '').trim();
|
||||
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
headings.push({ level: m[1].length, text, id });
|
||||
headings.push({ level: m[1].length, text, id, children: [] });
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
@@ -21,9 +34,9 @@ function parseHeadings(md) {
|
||||
|
||||
// ── Inject id attributes into rendered HTML headings ─────────────────
|
||||
|
||||
function injectHeadingIds(html, headings) {
|
||||
function injectHeadingIds(html: string, headings: Heading[]) {
|
||||
let idx = 0;
|
||||
return html.replace(/<(h[1-6])>/gi, (match, tag) => {
|
||||
return html.replace(/<(h[1-6])>/gi, (match: string, tag: string) => {
|
||||
if (idx < headings.length) {
|
||||
return `<${tag} id="${headings[idx++].id}">`;
|
||||
}
|
||||
@@ -33,9 +46,9 @@ function injectHeadingIds(html, headings) {
|
||||
|
||||
// ── Build a tree from flat heading list ──────────────────────────────
|
||||
|
||||
function buildTocTree(headings) {
|
||||
const root = { children: [] };
|
||||
const stack = [{ node: root, level: 0 }];
|
||||
function buildTocTree(headings: Heading[]): Heading[] {
|
||||
const root: { children: Heading[] } = { children: [] };
|
||||
const stack: { node: { children: Heading[] }; level: number }[] = [{ node: root, level: 0 }];
|
||||
for (const h of headings) {
|
||||
const item = { ...h, children: [] };
|
||||
while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop();
|
||||
@@ -47,7 +60,14 @@ function buildTocTree(headings) {
|
||||
|
||||
// ── TOC sidebar component ────────────────────────────────────────────
|
||||
|
||||
function TocItem({ item, collapsed, onToggle, onNavigate }) {
|
||||
interface TocItemProps {
|
||||
item: Heading;
|
||||
collapsed: Record<string, boolean>;
|
||||
onToggle: (id: string) => void;
|
||||
onNavigate: (id: string) => void;
|
||||
}
|
||||
|
||||
function TocItem({ item, collapsed, onToggle, onNavigate }: TocItemProps) {
|
||||
const hasChildren = item.children.length > 0;
|
||||
const isCollapsed = collapsed[item.id];
|
||||
return (
|
||||
@@ -82,13 +102,18 @@ function TocItem({ item, collapsed, onToggle, onNavigate }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Toc({ headings, contentRef }) {
|
||||
const [collapsed, setCollapsed] = useState({});
|
||||
interface TocProps {
|
||||
headings: Heading[];
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function Toc({ headings, contentRef }: TocProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const tree = useMemo(() => buildTocTree(headings), [headings]);
|
||||
|
||||
const onToggle = (id) => setCollapsed((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
const onToggle = (id: string) => setCollapsed((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
const onNavigate = (id) => {
|
||||
const onNavigate = (id: string) => {
|
||||
const el = contentRef.current?.querySelector(`#${CSS.escape(id)}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
@@ -108,14 +133,14 @@ function Toc({ headings, contentRef }) {
|
||||
|
||||
// ── Click handler for .md links ──────────────────────────────────────
|
||||
|
||||
function useMdLinkHandler(onOpenDoc) {
|
||||
return (e) => {
|
||||
const a = e.target.closest('a[href]');
|
||||
function useMdLinkHandler(onOpenDoc: (filename: string) => void) {
|
||||
return (e: React.MouseEvent<HTMLElement>) => {
|
||||
const a = (e.target as HTMLElement).closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (href && /\.md$/i.test(href) && !href.startsWith('http')) {
|
||||
e.preventDefault();
|
||||
const filename = href.split('/').pop();
|
||||
const filename = href.split('/').pop() ?? href;
|
||||
onOpenDoc(filename);
|
||||
}
|
||||
};
|
||||
@@ -123,14 +148,19 @@ function useMdLinkHandler(onOpenDoc) {
|
||||
|
||||
// ── Content pane with TOC ────────────────────────────────────────────
|
||||
|
||||
function HelpContent({ content, onOpenDoc }) {
|
||||
const contentRef = useRef(null);
|
||||
interface HelpContentProps {
|
||||
content: string;
|
||||
onOpenDoc: (filename: string) => void;
|
||||
}
|
||||
|
||||
function HelpContent({ content, onOpenDoc }: HelpContentProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const handleClick = useMdLinkHandler(onOpenDoc);
|
||||
const md = content || '*Loading…*';
|
||||
const headings = useMemo(() => parseHeadings(md), [md]);
|
||||
const html = useMemo(() => {
|
||||
let rendered;
|
||||
try { rendered = marked.parse(md); } catch { rendered = md; }
|
||||
let rendered: string;
|
||||
try { rendered = marked.parse(md) as string; } catch { rendered = md; }
|
||||
return injectHeadingIds(rendered, headings);
|
||||
}, [md, headings]);
|
||||
|
||||
@@ -150,16 +180,22 @@ function HelpContent({ content, onOpenDoc }) {
|
||||
|
||||
// ── Journal tab ──────────────────────────────────────────────────────
|
||||
|
||||
function JournalTab({ content, onChange, onOpenDoc }) {
|
||||
interface JournalTabProps {
|
||||
content: string;
|
||||
onChange: (value: string) => void;
|
||||
onOpenDoc: (filename: string) => void;
|
||||
}
|
||||
|
||||
function JournalTab({ content, onChange, onOpenDoc }: JournalTabProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const contentRef = useRef(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const handleClick = useMdLinkHandler(onOpenDoc);
|
||||
|
||||
let renderedHtml = '';
|
||||
let headings = [];
|
||||
let headings: Heading[] = [];
|
||||
if (!isEditing && content?.trim()) {
|
||||
headings = parseHeadings(content);
|
||||
try { renderedHtml = injectHeadingIds(marked.parse(content), headings); } catch { renderedHtml = content; }
|
||||
try { renderedHtml = injectHeadingIds(marked.parse(content) as string, headings); } catch { renderedHtml = content; }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -215,11 +251,21 @@ function JournalTab({ content, onChange, onOpenDoc }) {
|
||||
|
||||
// ── Main panel manager ───────────────────────────────────────────────
|
||||
|
||||
function HelpPanelManager({ tabs, activeTab, onTabSelect, onTabClose, onTabContentChange, onOpenJournal, onOpenDoc }) {
|
||||
interface HelpPanelManagerProps {
|
||||
tabs: HelpTab[];
|
||||
activeTab: string;
|
||||
onTabSelect: (label: string) => void;
|
||||
onTabClose: (label: string) => void;
|
||||
onTabContentChange: (label: string, value: string) => void;
|
||||
onOpenJournal: () => void;
|
||||
onOpenDoc: (filename: string) => void;
|
||||
}
|
||||
|
||||
function HelpPanelManager({ tabs, activeTab, onTabSelect, onTabClose, onTabContentChange, onOpenJournal, onOpenDoc }: HelpPanelManagerProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && activeTab) onTabClose(activeTab);
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
@@ -13,19 +13,19 @@ const MARKER_STROKE = '#ffffff';
|
||||
const MARKER_LOCKED_COLOR = '#e91e63';
|
||||
const MARKER_LABEL_FILL = '#0f172a';
|
||||
|
||||
function clamp(v, min, max) {
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function round3(v) {
|
||||
function round3(v: number) {
|
||||
return parseFloat(v.toFixed(3));
|
||||
}
|
||||
|
||||
function trimZeros(text) {
|
||||
function trimZeros(text: string) {
|
||||
return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1');
|
||||
}
|
||||
|
||||
function formatTick(value) {
|
||||
function formatTick(value: number) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs === 0) return '0';
|
||||
if (abs >= 1e4 || abs < 1e-3) {
|
||||
@@ -37,17 +37,17 @@ function formatTick(value) {
|
||||
return trimZeros(value.toFixed(3));
|
||||
}
|
||||
|
||||
function makeTicks(min, max, count = 5) {
|
||||
function makeTicks(min: number, max: number, count = 5) {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
|
||||
if (min === max) return [min];
|
||||
const ticks = [];
|
||||
const ticks: number[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
ticks.push(min + ((max - min) * i) / (count - 1));
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
|
||||
function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return [fallbackMin, fallbackMax];
|
||||
}
|
||||
@@ -66,6 +66,17 @@ function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
interface LinePlotOverlayProps {
|
||||
overlay: any;
|
||||
x1: number;
|
||||
x2: number;
|
||||
aLocked: boolean;
|
||||
bLocked: boolean;
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export default function LinePlotOverlay({
|
||||
overlay,
|
||||
x1,
|
||||
@@ -75,9 +86,9 @@ export default function LinePlotOverlay({
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
interactive = true,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
}: LinePlotOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,10 +121,10 @@ export default function LinePlotOverlay({
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
|
||||
const xValues: number[] = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
|
||||
? overlay.x_axis
|
||||
: overlay?.line?.map((_, i) => i) || [];
|
||||
const yValues = Array.isArray(overlay?.line) ? overlay.line : [];
|
||||
: overlay?.line?.map((_: unknown, i: number) => i) || [];
|
||||
const yValues: number[] = Array.isArray(overlay?.line) ? overlay.line : [];
|
||||
|
||||
const width = size.width || 320;
|
||||
const height = size.height || Math.round(width / ASPECT_RATIO);
|
||||
@@ -128,17 +139,17 @@ export default function LinePlotOverlay({
|
||||
const yMin = yMinRaw - yPad;
|
||||
const yMax = yMaxRaw + yPad;
|
||||
|
||||
const scaleX = useCallback((value) => {
|
||||
const scaleX = useCallback((value: number) => {
|
||||
if (xMax === xMin) return plotLeft + plotWidth / 2;
|
||||
return plotLeft + ((value - xMin) / (xMax - xMin)) * plotWidth;
|
||||
}, [plotLeft, plotWidth, xMin, xMax]);
|
||||
|
||||
const scaleY = useCallback((value) => {
|
||||
const scaleY = useCallback((value: number) => {
|
||||
if (yMax === yMin) return plotTop + plotHeight / 2;
|
||||
return plotTop + (1 - ((value - yMin) / (yMax - yMin))) * plotHeight;
|
||||
}, [plotTop, plotHeight, yMin, yMax]);
|
||||
|
||||
const pickCursorPoint = useCallback((fraction) => {
|
||||
const pickCursorPoint = useCallback((fraction: number) => {
|
||||
if (!xValues.length || !yValues.length) {
|
||||
return {
|
||||
x: plotLeft,
|
||||
@@ -173,7 +184,7 @@ export default function LinePlotOverlay({
|
||||
const cursorA = pickCursorPoint(x1 ?? overlay?.x1 ?? 0.25);
|
||||
const cursorB = pickCursorPoint(x2 ?? overlay?.x2 ?? 0.75);
|
||||
|
||||
const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
|
||||
const path = yValues.map((y: number, i: number) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
|
||||
const xTickCount = Math.max(2, Math.min(5, Math.floor(plotWidth / 70)));
|
||||
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
|
||||
const xTicks = makeTicks(xMin, xMax, xTickCount);
|
||||
@@ -187,7 +198,7 @@ export default function LinePlotOverlay({
|
||||
const markerRadius = clamp(plotWidth / 42, 5.5, 9);
|
||||
const markerLabelSize = clamp(plotWidth / 34, 8, 11);
|
||||
|
||||
const updateCursor = useCallback((point, event) => {
|
||||
const updateCursor = useCallback((point: string, event: React.PointerEvent<Element>) => {
|
||||
if (!interactive || !onWidgetChange || !nodeId) return;
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
@@ -202,7 +213,7 @@ export default function LinePlotOverlay({
|
||||
}
|
||||
}, [interactive, nodeId, onWidgetChange, pickCursorPoint, plotLeft, plotWidth]);
|
||||
|
||||
const onPointerDown = useCallback((point) => (event) => {
|
||||
const onPointerDown = useCallback((point: string) => (event: React.PointerEvent<Element>) => {
|
||||
if (!interactive) return;
|
||||
if ((point === 'p1' && aLocked) || (point === 'p2' && bLocked)) return;
|
||||
event.preventDefault();
|
||||
@@ -211,7 +222,7 @@ export default function LinePlotOverlay({
|
||||
setDragging(point);
|
||||
}, [interactive, aLocked, bLocked]);
|
||||
|
||||
const onPointerMove = useCallback((event) => {
|
||||
const onPointerMove = useCallback((event: React.PointerEvent<Element>) => {
|
||||
if (!dragging) return;
|
||||
updateCursor(dragging, event);
|
||||
}, [dragging, updateCursor]);
|
||||
|
||||
@@ -11,13 +11,29 @@ import {
|
||||
sanitizeMarkupShape,
|
||||
} from './markupShapeGeometry';
|
||||
|
||||
function clampFraction(value) {
|
||||
function clampFraction(value: number) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
function ShapeElement({ shape, imageWidth, imageHeight }) {
|
||||
interface MarkupShape {
|
||||
kind: string;
|
||||
color: string;
|
||||
width: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface ShapeElementProps {
|
||||
shape: MarkupShape;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}
|
||||
|
||||
function ShapeElement({ shape, imageWidth, imageHeight }: ShapeElementProps) {
|
||||
const x1 = shape.x1 * imageWidth;
|
||||
const y1 = shape.y1 * imageHeight;
|
||||
const x2 = shape.x2 * imageWidth;
|
||||
@@ -29,11 +45,11 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
|
||||
const strokeWidth = getMarkupPreviewStrokeWidth(shape.width, imageWidth, imageHeight);
|
||||
const renderShape = { ...shape, width: strokeWidth };
|
||||
const common = {
|
||||
fill: 'none',
|
||||
fill: 'none' as const,
|
||||
stroke: shape.color,
|
||||
strokeWidth,
|
||||
strokeLinecap: shape.kind === 'arrow' ? 'square' : 'round',
|
||||
strokeLinejoin: 'round',
|
||||
strokeLinecap: (shape.kind === 'arrow' ? 'square' : 'round') as 'square' | 'round',
|
||||
strokeLinejoin: 'round' as const,
|
||||
};
|
||||
|
||||
if (shape.kind === 'line') {
|
||||
@@ -68,6 +84,16 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkupOverlayProps {
|
||||
image: string;
|
||||
shape: string;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
markupShapes: string | MarkupShape[];
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export default function MarkupOverlay({
|
||||
image,
|
||||
shape,
|
||||
@@ -76,11 +102,11 @@ export default function MarkupOverlay({
|
||||
markupShapes,
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const shapesRef = useRef([]);
|
||||
const [draftShape, setDraftShape] = useState(null);
|
||||
}: MarkupOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const shapesRef = useRef<MarkupShape[]>([]);
|
||||
const [draftShape, setDraftShape] = useState<MarkupShape | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
|
||||
|
||||
@@ -123,7 +149,7 @@ export default function MarkupOverlay({
|
||||
return undefined;
|
||||
}, [image]);
|
||||
|
||||
const getPoint = useCallback((event) => {
|
||||
const getPoint = useCallback((event: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
return {
|
||||
@@ -132,13 +158,13 @@ export default function MarkupOverlay({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitShapes = useCallback((nextShapes) => {
|
||||
const commitShapes = useCallback((nextShapes: MarkupShape[]) => {
|
||||
if (!nodeId || !onWidgetChange) return;
|
||||
onWidgetChange(nodeId, 'markup_shapes', JSON.stringify(nextShapes));
|
||||
}, [nodeId, onWidgetChange]);
|
||||
|
||||
const handlePointerDown = useCallback((event) => {
|
||||
if (!onWidgetChange || event.target.closest('button')) return;
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<Element>) => {
|
||||
if (!onWidgetChange || (event.target as HTMLElement).closest('button')) return;
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
event.preventDefault();
|
||||
@@ -156,7 +182,7 @@ export default function MarkupOverlay({
|
||||
});
|
||||
}, [getPoint, normalizedColor, normalizedShape, normalizedWidth, onWidgetChange]);
|
||||
|
||||
const handlePointerMove = useCallback((event) => {
|
||||
const handlePointerMove = useCallback((event: React.PointerEvent<Element>) => {
|
||||
if (!drawing) return;
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
|
||||
@@ -1,48 +1,63 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { CANVAS_COLORS } from './constants';
|
||||
|
||||
function clampFraction(value) {
|
||||
interface StrokePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Stroke {
|
||||
size: number;
|
||||
points: StrokePoint[];
|
||||
}
|
||||
|
||||
interface DrawStrokeStyles {
|
||||
strokeStyle?: string;
|
||||
fillStyle?: string;
|
||||
}
|
||||
|
||||
function clampFraction(value: number) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
function sanitizeStroke(stroke, fallbackPenSize) {
|
||||
function sanitizeStroke(stroke: any, fallbackPenSize: number): Stroke | null {
|
||||
if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points) || stroke.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = Math.max(1, Math.round(Number(stroke.size) || fallbackPenSize || 1));
|
||||
const points = stroke.points
|
||||
.map((point) => {
|
||||
.map((point: any) => {
|
||||
if (!point || typeof point !== 'object') return null;
|
||||
return {
|
||||
x: Number(clampFraction(point.x).toFixed(4)),
|
||||
y: Number(clampFraction(point.y).toFixed(4)),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter(Boolean) as StrokePoint[];
|
||||
|
||||
if (points.length === 0) return null;
|
||||
return { size, points };
|
||||
}
|
||||
|
||||
function parseMaskPaths(maskPaths, fallbackPenSize) {
|
||||
function parseMaskPaths(maskPaths: any, fallbackPenSize: number): Stroke[] {
|
||||
if (Array.isArray(maskPaths)) {
|
||||
return maskPaths.map((stroke) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean);
|
||||
return maskPaths.map((stroke: any) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean) as Stroke[];
|
||||
}
|
||||
if (typeof maskPaths !== 'string' || !maskPaths.trim()) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(maskPaths);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map((stroke) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean);
|
||||
return parsed.map((stroke: any) => sanitizeStroke(stroke, fallbackPenSize)).filter(Boolean) as Stroke[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function drawStroke(ctx, stroke, width, height, imageWidth, imageHeight, styles = {}) {
|
||||
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, width: number, height: number, imageWidth: number, imageHeight: number, styles: DrawStrokeStyles = {}) {
|
||||
if (!stroke || !Array.isArray(stroke.points) || stroke.points.length === 0) return;
|
||||
|
||||
const scaleX = imageWidth > 0 ? width / imageWidth : 1;
|
||||
@@ -87,6 +102,16 @@ function drawStroke(ctx, stroke, width, height, imageWidth, imageHeight, styles
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface MaskPaintOverlayProps {
|
||||
image: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
penSize: number;
|
||||
maskPaths: any;
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export default function MaskPaintOverlay({
|
||||
image,
|
||||
imageWidth,
|
||||
@@ -95,15 +120,15 @@ export default function MaskPaintOverlay({
|
||||
maskPaths,
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const strokesRef = useRef([]);
|
||||
const draftStrokeRef = useRef(null);
|
||||
const [strokes, setStrokes] = useState(() => parseMaskPaths(maskPaths, penSize));
|
||||
const [draftStroke, setDraftStroke] = useState(null);
|
||||
}: MaskPaintOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const strokesRef = useRef<Stroke[]>([]);
|
||||
const draftStrokeRef = useRef<Stroke | null>(null);
|
||||
const [strokes, setStrokes] = useState<Stroke[]>(() => parseMaskPaths(maskPaths, penSize));
|
||||
const [draftStroke, setDraftStroke] = useState<Stroke | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [cursorPoint, setCursorPoint] = useState(null);
|
||||
const [cursorPoint, setCursorPoint] = useState<StrokePoint | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseMaskPaths(maskPaths, penSize);
|
||||
@@ -121,7 +146,7 @@ export default function MaskPaintOverlay({
|
||||
draftStrokeRef.current = draftStroke;
|
||||
}, [draftStroke]);
|
||||
|
||||
const redrawCanvas = useCallback((committedStrokes, activeStroke) => {
|
||||
const redrawCanvas = useCallback((committedStrokes: Stroke[], activeStroke: Stroke | null) => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
@@ -154,7 +179,7 @@ export default function MaskPaintOverlay({
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.scale(dpr, dpr);
|
||||
|
||||
const drawMaskStroke = (stroke) => drawStroke(
|
||||
const drawMaskStroke = (stroke: Stroke) => drawStroke(
|
||||
maskCtx,
|
||||
stroke,
|
||||
cssWidth,
|
||||
@@ -193,7 +218,7 @@ export default function MaskPaintOverlay({
|
||||
return () => observer.disconnect();
|
||||
}, [draftStroke, redrawCanvas]);
|
||||
|
||||
const getPoint = useCallback((event) => {
|
||||
const getPoint = useCallback((event: React.PointerEvent<Element>): StrokePoint | null => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
return {
|
||||
@@ -211,7 +236,7 @@ export default function MaskPaintOverlay({
|
||||
return Math.max(1, (Math.max(1, Math.round(Number(penSize) || 1)) * brushScale));
|
||||
}, [imageHeight, imageWidth, penSize]);
|
||||
|
||||
const appendPoint = useCallback((stroke, point) => {
|
||||
const appendPoint = useCallback((stroke: Stroke | null, point: StrokePoint | null): Stroke | null => {
|
||||
if (!stroke || !point) return stroke;
|
||||
const lastPoint = stroke.points[stroke.points.length - 1];
|
||||
if (lastPoint && Math.abs(lastPoint.x - point.x) < 0.001 && Math.abs(lastPoint.y - point.y) < 0.001) {
|
||||
@@ -223,7 +248,7 @@ export default function MaskPaintOverlay({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitStroke = useCallback((stroke) => {
|
||||
const commitStroke = useCallback((stroke: Stroke | null) => {
|
||||
const normalizedStroke = sanitizeStroke(stroke, penSize);
|
||||
setDraftStroke(null);
|
||||
setDrawing(false);
|
||||
@@ -235,8 +260,8 @@ export default function MaskPaintOverlay({
|
||||
onWidgetChange(nodeId, 'mask_paths', JSON.stringify(nextStrokes));
|
||||
}, [nodeId, onWidgetChange, penSize]);
|
||||
|
||||
const handlePointerDown = useCallback((event) => {
|
||||
if (event.target.closest('button')) return;
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<Element>) => {
|
||||
if ((event.target as HTMLElement).closest('button')) return;
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
|
||||
@@ -251,7 +276,7 @@ export default function MaskPaintOverlay({
|
||||
});
|
||||
}, [getPoint, penSize]);
|
||||
|
||||
const handlePointerMove = useCallback((event) => {
|
||||
const handlePointerMove = useCallback((event: React.PointerEvent<Element>) => {
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
setCursorPoint(point);
|
||||
|
||||
@@ -10,7 +10,62 @@ const DEFAULT_CAMERA_STATE = {
|
||||
distance: 1.8,
|
||||
};
|
||||
|
||||
function getFiniteNumber(...values) {
|
||||
interface MeshData {
|
||||
width: number;
|
||||
height: number;
|
||||
z_data?: string;
|
||||
colors?: string;
|
||||
z_min: number;
|
||||
z_max: number;
|
||||
z_scale: number;
|
||||
x_range?: [number, number];
|
||||
y_range?: [number, number];
|
||||
positions?: string;
|
||||
indices?: string;
|
||||
vertex_colors?: string;
|
||||
surface_extent_x?: number;
|
||||
surface_extent_y?: number;
|
||||
make_solid?: boolean;
|
||||
}
|
||||
|
||||
interface CameraState {
|
||||
azimuth?: number;
|
||||
polar?: number;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface DiagnosticsState {
|
||||
status: string;
|
||||
webgl: string;
|
||||
canvas: string;
|
||||
mesh: string;
|
||||
bounds: string;
|
||||
camera: string;
|
||||
target: string;
|
||||
render: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ThreeState {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
controls: OrbitControls;
|
||||
mesh: THREE.Mesh | null;
|
||||
animId: number | undefined;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
meshData: MeshData | null;
|
||||
nodeId?: string;
|
||||
widgetValues?: Record<string, unknown>;
|
||||
runtimeValues?: Record<string, unknown>;
|
||||
onRuntimeValuesChange?: (nodeId: string, patch: Record<string, unknown>, options: { scheduleRun: boolean }) => void;
|
||||
}
|
||||
|
||||
type TypedArrayConstructor = Float32ArrayConstructor | Uint8ArrayConstructor | Uint32ArrayConstructor;
|
||||
|
||||
function getFiniteNumber(...values: (number | string | null | undefined)[]): number | null {
|
||||
for (const value of values) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) {
|
||||
@@ -20,17 +75,17 @@ function getFiniteNumber(...values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 2) {
|
||||
function formatNumber(value: number | string | null | undefined, digits = 2): string {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric.toFixed(digits) : 'n/a';
|
||||
}
|
||||
|
||||
function formatVector3(value, digits = 2) {
|
||||
function formatVector3(value: THREE.Vector3 | null | undefined, digits = 2): string {
|
||||
if (!value) return 'n/a';
|
||||
return `${formatNumber(value.x, digits)}, ${formatNumber(value.y, digits)}, ${formatNumber(value.z, digits)}`;
|
||||
}
|
||||
|
||||
function areView3dDiagnosticsEnabled() {
|
||||
function areView3dDiagnosticsEnabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.localStorage?.getItem(VIEW3D_DIAGNOSTICS_STORAGE_KEY) === '1';
|
||||
@@ -39,7 +94,7 @@ function areView3dDiagnosticsEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildGeometrySignature(meshData) {
|
||||
function buildGeometrySignature(meshData: MeshData | null): string {
|
||||
if (!meshData) return '';
|
||||
const positionSource = String(meshData.positions || meshData.z_data || '');
|
||||
const indexSource = String(meshData.indices || '');
|
||||
@@ -65,20 +120,20 @@ function buildGeometrySignature(meshData) {
|
||||
* meshData: { width, height, z_data (b64 float32), colors (b64 uint8 RGB),
|
||||
* z_min, z_max, z_scale, x_range, y_range }
|
||||
*/
|
||||
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) {
|
||||
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }: Props) {
|
||||
const [showDiagnostics] = useState(() => areView3dDiagnosticsEnabled());
|
||||
const containerRef = useRef(null);
|
||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const threeRef = useRef<ThreeState | null>(null); // { renderer, scene, camera, controls, mesh }
|
||||
const meshCenterRef = useRef(new THREE.Vector3());
|
||||
const fitDistanceRef = useRef(DEFAULT_CAMERA_STATE.distance);
|
||||
const lastGeometrySignatureRef = useRef('');
|
||||
const syncTimerRef = useRef(null);
|
||||
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSnapshotRef = useRef('');
|
||||
const isInsideRef = useRef(false);
|
||||
const pointerEnteredAtRef = useRef(0);
|
||||
const lastWheelAtRef = useRef(0);
|
||||
const gestureStartedInsideRef = useRef(false);
|
||||
const [diagnostics, setDiagnostics] = useState({
|
||||
const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({
|
||||
status: meshData ? 'initializing' : 'waiting for mesh',
|
||||
webgl: 'pending',
|
||||
canvas: 'n/a',
|
||||
@@ -90,17 +145,17 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
error: '',
|
||||
});
|
||||
|
||||
const updateDiagnostics = useCallback((patch) => {
|
||||
const updateDiagnostics = useCallback((patch: Partial<DiagnosticsState>) => {
|
||||
if (!showDiagnostics) return;
|
||||
setDiagnostics((prev) => ({ ...prev, ...patch }));
|
||||
}, [showDiagnostics]);
|
||||
|
||||
// Decode base64 to typed arrays
|
||||
const decode = useCallback((b64, ArrayType) => {
|
||||
const decode = useCallback(<T extends TypedArrayConstructor>(b64: string, ArrayType: T): InstanceType<T> => {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new ArrayType(bytes.buffer);
|
||||
return new ArrayType(bytes.buffer) as InstanceType<T>;
|
||||
}, []);
|
||||
|
||||
const captureViewportSnapshot = useCallback(() => {
|
||||
@@ -108,11 +163,11 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
if (!canvas) return null;
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.warn('[tono] Failed to capture View3D viewport snapshot', error);
|
||||
updateDiagnostics({
|
||||
status: 'snapshot error',
|
||||
error: error?.message || String(error),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -130,7 +185,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`,
|
||||
});
|
||||
if (!nodeId || !onRuntimeValuesChange) return;
|
||||
const patch = {};
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (snapshot && snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
||||
@@ -150,24 +205,24 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
}, delay);
|
||||
}, [syncViewportState]);
|
||||
|
||||
const applyCameraState = useCallback((cameraState = {}) => {
|
||||
const applyCameraState = useCallback((cameraState: CameraState = {}) => {
|
||||
const state = threeRef.current;
|
||||
if (!state) return;
|
||||
const { camera, controls } = state;
|
||||
const target = meshCenterRef.current.clone();
|
||||
const distance = THREE.MathUtils.clamp(
|
||||
getFiniteNumber(cameraState.distance, fitDistanceRef.current, DEFAULT_CAMERA_STATE.distance),
|
||||
getFiniteNumber(cameraState.distance, fitDistanceRef.current, DEFAULT_CAMERA_STATE.distance) ?? DEFAULT_CAMERA_STATE.distance,
|
||||
controls.minDistance,
|
||||
controls.maxDistance,
|
||||
);
|
||||
const spherical = new THREE.Spherical(
|
||||
distance,
|
||||
THREE.MathUtils.clamp(
|
||||
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
|
||||
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar) ?? DEFAULT_CAMERA_STATE.polar,
|
||||
0.01,
|
||||
Math.PI - 0.01,
|
||||
),
|
||||
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth),
|
||||
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth) ?? DEFAULT_CAMERA_STATE.azimuth,
|
||||
);
|
||||
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
||||
controls.target.copy(target);
|
||||
@@ -209,7 +264,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
error: '',
|
||||
});
|
||||
|
||||
const handleContextLost = (event) => {
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
updateDiagnostics({
|
||||
status: 'webgl context lost',
|
||||
@@ -262,7 +317,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
scene.add(dir2);
|
||||
|
||||
// Animation loop
|
||||
let animId;
|
||||
let animId: number | undefined;
|
||||
const animate = () => {
|
||||
animId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
@@ -270,7 +325,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
};
|
||||
animate();
|
||||
|
||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId } as ThreeState;
|
||||
applyCameraState({
|
||||
azimuth: DEFAULT_CAMERA_STATE.azimuth,
|
||||
polar: DEFAULT_CAMERA_STATE.polar,
|
||||
@@ -294,7 +349,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(animId);
|
||||
if (animId !== undefined) cancelAnimationFrame(animId);
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
controls.removeEventListener('end', handleControlsEnd);
|
||||
renderer.domElement.removeEventListener('webglcontextlost', handleContextLost, false);
|
||||
@@ -348,15 +403,20 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
if (threeRef.current.mesh) {
|
||||
scene.remove(threeRef.current.mesh);
|
||||
threeRef.current.mesh.geometry.dispose();
|
||||
threeRef.current.mesh.material.dispose();
|
||||
const mat = threeRef.current.mesh.material;
|
||||
if (Array.isArray(mat)) {
|
||||
mat.forEach((m) => m.dispose());
|
||||
} else {
|
||||
mat.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Build geometry
|
||||
const geom = new THREE.BufferGeometry();
|
||||
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
||||
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
|
||||
const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0);
|
||||
const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0);
|
||||
const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0) ?? 1.0;
|
||||
const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0) ?? 1.0;
|
||||
|
||||
if (!posArr) {
|
||||
const zRange = z_max - z_min || 1;
|
||||
@@ -365,7 +425,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
const idx = iy * nx + ix;
|
||||
const px = (ix / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX;
|
||||
const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY;
|
||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||
const pz = ((zArr![idx] - z_min) / zRange - 0.5) * z_scale;
|
||||
|
||||
positionsArray[idx * 3] = px;
|
||||
positionsArray[idx * 3 + 1] = pz;
|
||||
@@ -453,11 +513,11 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
lastGeometrySignatureRef.current = geometrySignature;
|
||||
}
|
||||
scheduleViewportSync(0, false);
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.error('[tono] View3D mesh build failed', error);
|
||||
updateDiagnostics({
|
||||
status: 'mesh build error',
|
||||
error: error?.message || String(error),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [applyCameraState, decode, meshData, scheduleViewportSync, updateDiagnostics]);
|
||||
@@ -494,7 +554,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
};
|
||||
|
||||
// Bubble phase: fires after OrbitControls has already run (or skipped due to enableZoom=false)
|
||||
const onWheelBubble = (e) => {
|
||||
const onWheelBubble = (e: WheelEvent) => {
|
||||
if (threeRef.current) {
|
||||
threeRef.current.controls.enableZoom = true;
|
||||
}
|
||||
@@ -517,7 +577,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onContextMenu = useCallback((e) => {
|
||||
const onContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useContext, useRef, useState, useEffect, useCallback, useMemo }
|
||||
import { NodeResizeControl, useStore } from '@xyflow/react';
|
||||
import { marked } from 'marked';
|
||||
import { NodeContext } from './CustomNode';
|
||||
import type { NodeContextValue } from './types';
|
||||
|
||||
marked.use({ breaks: true, gfm: true });
|
||||
|
||||
const NOTE_COLORS = {
|
||||
const NOTE_COLORS: Record<string, { bg: string; border: string; dot: string }> = {
|
||||
default: { bg: '#1e293b', border: '#334155', dot: '#475569' },
|
||||
blue: { bg: '#0c1f3d', border: '#1d4ed8', dot: '#3b82f6' },
|
||||
green: { bg: '#062016', border: '#15803d', dot: '#22c55e' },
|
||||
@@ -14,16 +15,21 @@ const NOTE_COLORS = {
|
||||
purple: { bg: '#160c2a', border: '#7c3aed', dot: '#a855f7' },
|
||||
};
|
||||
|
||||
function TextNoteNode({ id, data }) {
|
||||
const ctx = useContext(NodeContext);
|
||||
interface TextNoteNodeProps {
|
||||
id: string;
|
||||
data: { widgetValues?: Record<string, any>; [key: string]: any };
|
||||
}
|
||||
|
||||
function TextNoteNode({ id, data }: TextNoteNodeProps) {
|
||||
const ctx = useContext(NodeContext) as NodeContextValue | null;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const selected = useStore(
|
||||
useCallback(
|
||||
(s) => {
|
||||
const node = s.nodeLookup?.get(id) || s.nodes?.find((n) => n.id === id);
|
||||
(s: any) => {
|
||||
const node = s.nodeLookup?.get(id) || s.nodes?.find((n: any) => n.id === id);
|
||||
return !!node?.selected;
|
||||
},
|
||||
[id],
|
||||
@@ -35,7 +41,7 @@ function TextNoteNode({ id, data }) {
|
||||
const palette = NOTE_COLORS[color] ?? NOTE_COLORS.default;
|
||||
|
||||
const setField = useCallback(
|
||||
(name, value) => ctx?.onWidgetChange?.(id, name, value),
|
||||
(name: string, value: unknown) => ctx?.onWidgetChange?.(id, name, value),
|
||||
[ctx, id],
|
||||
);
|
||||
|
||||
@@ -45,14 +51,14 @@ function TextNoteNode({ id, data }) {
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const onDoubleClick = useCallback((e) => {
|
||||
const onDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(() => setIsEditing(false), []);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Ctrl/Cmd+Enter or Escape finishes editing
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
@@ -62,6 +68,7 @@ function TextNoteNode({ id, data }) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const next = text.substring(0, start) + ' ' + text.substring(end);
|
||||
|
||||
@@ -13,11 +13,11 @@ const MARKER_STROKE = '#ffffff';
|
||||
const MARKER_LOCKED_COLOR = '#e91e63';
|
||||
const MARKER_LABEL_FILL = '#0f172a';
|
||||
|
||||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||||
function round4(v) { return parseFloat(v.toFixed(4)); }
|
||||
function trimZeros(t) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
|
||||
function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); }
|
||||
function round4(v: number) { return parseFloat(v.toFixed(4)); }
|
||||
function trimZeros(t: string) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
|
||||
|
||||
function formatTick(value) {
|
||||
function formatTick(value: number) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs === 0) return '0';
|
||||
if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e');
|
||||
@@ -27,20 +27,28 @@ function formatTick(value) {
|
||||
return trimZeros(value.toFixed(3));
|
||||
}
|
||||
|
||||
function makeTicks(min, max, count = 5) {
|
||||
function makeTicks(min: number, max: number, count = 5) {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min];
|
||||
return Array.from({ length: count }, (_, i) => min + (max - min) * i / (count - 1));
|
||||
return Array.from({ length: count }, (_: unknown, i: number) => min + (max - min) * i / (count - 1));
|
||||
}
|
||||
|
||||
function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
|
||||
function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) {
|
||||
if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax];
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (const v of values) { if (Number.isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } }
|
||||
return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax];
|
||||
}
|
||||
|
||||
export default function ThresholdHistogram({ overlay, threshold, thresholdConnected, nodeId, onWidgetChange }) {
|
||||
const containerRef = useRef(null);
|
||||
interface ThresholdHistogramProps {
|
||||
overlay: any;
|
||||
threshold: number;
|
||||
thresholdConnected: boolean;
|
||||
nodeId: string;
|
||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export default function ThresholdHistogram({ overlay, threshold, thresholdConnected, nodeId, onWidgetChange }: ThresholdHistogramProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [size, setSize] = useState({ width: 0 });
|
||||
|
||||
@@ -63,9 +71,9 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
|
||||
? overlay.x_axis : overlay?.line?.map((_, i) => i) || [];
|
||||
const yValues = Array.isArray(overlay?.line) ? overlay.line : [];
|
||||
const xValues: number[] = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
|
||||
? overlay.x_axis : overlay?.line?.map((_: unknown, i: number) => i) || [];
|
||||
const yValues: number[] = Array.isArray(overlay?.line) ? overlay.line : [];
|
||||
const method = overlay?.method ?? 'absolute';
|
||||
const locked = (overlay?.locked ?? false) || !!thresholdConnected;
|
||||
const xMin = overlay?.x_min ?? 0;
|
||||
@@ -84,12 +92,12 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
|
||||
const yMin = yMinRaw - yPad;
|
||||
const yMax = yMaxRaw + yPad;
|
||||
|
||||
const scaleX = useCallback((v) => {
|
||||
const scaleX = useCallback((v: number) => {
|
||||
if (xExtMax === xExtMin) return plotLeft + plotWidth / 2;
|
||||
return plotLeft + (v - xExtMin) / (xExtMax - xExtMin) * plotWidth;
|
||||
}, [plotLeft, plotWidth, xExtMin, xExtMax]);
|
||||
|
||||
const scaleY = useCallback((v) => {
|
||||
const scaleY = useCallback((v: number) => {
|
||||
if (yMax === yMin) return plotTop + plotHeight / 2;
|
||||
return plotTop + (1 - (v - yMin) / (yMax - yMin)) * plotHeight;
|
||||
}, [plotTop, plotHeight, yMin, yMax]);
|
||||
@@ -116,7 +124,7 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
|
||||
return scaleY(yValues[best]);
|
||||
})();
|
||||
|
||||
const handleDrag = useCallback((e) => {
|
||||
const handleDrag = useCallback((e: React.PointerEvent<Element>) => {
|
||||
if (!onWidgetChange || !nodeId || locked || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const frac = clamp((e.clientX - rect.left - plotLeft) / plotWidth, 0, 1);
|
||||
@@ -128,7 +136,7 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
|
||||
onWidgetChange(nodeId, 'threshold', newThreshold);
|
||||
}, [onWidgetChange, nodeId, locked, plotLeft, plotWidth, method, xMin, xMax]);
|
||||
|
||||
const onPointerDown = useCallback((e) => {
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<Element>) => {
|
||||
if (locked) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -136,13 +144,13 @@ export default function ThresholdHistogram({ overlay, threshold, thresholdConnec
|
||||
setDragging(true);
|
||||
}, [locked]);
|
||||
|
||||
const onPointerMove = useCallback((e) => {
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||
if (dragging) handleDrag(e);
|
||||
}, [dragging, handleDrag]);
|
||||
|
||||
const onPointerUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
|
||||
const path = yValues.map((y: number, i: number) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
|
||||
const xTickCount = Math.max(2, Math.min(5, Math.floor(plotWidth / 70)));
|
||||
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
|
||||
const xTicks = makeTicks(xExtMin, xExtMax, xTickCount);
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
function clamp01(value) {
|
||||
interface AnglePoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
xm: number;
|
||||
ym: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
export function round3(value) {
|
||||
export function round3(value: number): number {
|
||||
return Number.parseFloat(Number(value).toFixed(3));
|
||||
}
|
||||
|
||||
export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
|
||||
export function getAngleLabelBasePosition(x1: number, y1: number, xm: number, ym: number, x2: number, y2: number) {
|
||||
const va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
|
||||
const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) };
|
||||
const lenA = Math.hypot(va.x, va.y);
|
||||
@@ -31,7 +40,7 @@ export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
|
||||
export function getAngleLabelPosition(points: AnglePoints, labelDx: number = 0, labelDy: number = 0) {
|
||||
const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
|
||||
return {
|
||||
x: clamp01(base.x + (Number(labelDx) || 0)),
|
||||
@@ -39,7 +48,7 @@ export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
export function moveAngleWidget(points, dx, dy) {
|
||||
export function moveAngleWidget(points: AnglePoints, dx: number, dy: number) {
|
||||
const nextDx = Number(dx) || 0;
|
||||
const nextDy = Number(dy) || 0;
|
||||
const xs = [points.x1, points.xm, points.x2];
|
||||
@@ -61,7 +70,7 @@ export function moveAngleWidget(points, dx, dy) {
|
||||
};
|
||||
}
|
||||
|
||||
export function measureAngleDegrees(x1, y1, xm, ym, x2, y2) {
|
||||
export function measureAngleDegrees(x1: number, y1: number, xm: number, ym: number, x2: number, y2: number) {
|
||||
const ax = Number(x1) - Number(xm);
|
||||
const ay = Number(y1) - Number(ym);
|
||||
const bx = Number(x2) - Number(xm);
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
const SESSION_STORAGE_KEY = 'tono-session-id';
|
||||
|
||||
let _sessionId = null;
|
||||
let _ws = null;
|
||||
let _handler = null;
|
||||
let _reconnectTimer = null;
|
||||
let _sessionId: string | null = null;
|
||||
let _ws: WebSocket | null = null;
|
||||
let _handler: ((msg: any) => void) | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
function generateSessionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
@@ -46,13 +46,13 @@ export function getSessionId() {
|
||||
return _sessionId;
|
||||
}
|
||||
|
||||
function withSessionHeaders(init = {}) {
|
||||
function withSessionHeaders(init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('X-Argonode-Session', getSessionId());
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
async function sessionFetch(input, init) {
|
||||
async function sessionFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, withSessionHeaders(init));
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function getNodes() {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getNodeDoc(displayName) {
|
||||
export async function getNodeDoc(displayName: string) {
|
||||
const r = await sessionFetch(`/docs?name=${encodeURIComponent(displayName)}`);
|
||||
if (!r.ok) return null;
|
||||
return r.text();
|
||||
@@ -74,7 +74,7 @@ export async function getFiles() {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function createUploadFolder(relativePath) {
|
||||
export async function createUploadFolder(relativePath: string) {
|
||||
const r = await sessionFetch('/upload-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -84,7 +84,7 @@ export async function createUploadFolder(relativePath) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function uploadFile(file, { relativePath = '' } = {}) {
|
||||
export async function uploadFile(file: File, { relativePath = '' } = {}) {
|
||||
const fd = new FormData();
|
||||
if (relativePath) fd.append('relative_path', relativePath);
|
||||
fd.append('file', file);
|
||||
@@ -96,7 +96,7 @@ export async function uploadFile(file, { relativePath = '' } = {}) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function uploadPlugin(file) {
|
||||
export async function uploadPlugin(file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch('/upload-plugin', { method: 'POST', body: fd });
|
||||
@@ -110,13 +110,13 @@ export async function uploadPlugin(file) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getChannels(filepath) {
|
||||
export async function getChannels(filepath: string) {
|
||||
const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`);
|
||||
if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getFileContent(path) {
|
||||
export async function getFileContent(path: string) {
|
||||
const r = await sessionFetch(`/file-content?path=${encodeURIComponent(path)}`);
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
@@ -125,13 +125,13 @@ export async function getFileContent(path) {
|
||||
return r.arrayBuffer();
|
||||
}
|
||||
|
||||
export async function getFolderFiles(folderpath) {
|
||||
export async function getFolderFiles(folderpath: string) {
|
||||
const r = await sessionFetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function runPrompt(prompt) {
|
||||
export async function runPrompt(prompt: Record<string, unknown>) {
|
||||
const r = await sessionFetch('/prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -144,7 +144,7 @@ export async function runPrompt(prompt) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function setMessageHandler(fn) {
|
||||
export function setMessageHandler(fn: ((msg: any) => void) | null) {
|
||||
_handler = fn;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,27 @@
|
||||
// Pure functions extracted from App.jsx so they can be independently tested.
|
||||
|
||||
import { socketSpecAcceptsType } from './constants.ts';
|
||||
import type { InputSpec, TonoNode } from './types.ts';
|
||||
|
||||
// ── Handle ID helpers ─────────────────────────────────────────────────
|
||||
|
||||
export function getHandleType(handleId) {
|
||||
export function getHandleType(handleId: string): string {
|
||||
return handleId.split('::')[2];
|
||||
}
|
||||
|
||||
export function getInputName(handleId) {
|
||||
export function getInputName(handleId: string): string {
|
||||
return handleId.split('::')[1];
|
||||
}
|
||||
|
||||
export function getOutputSlot(handleId) {
|
||||
export function getOutputSlot(handleId: string): number {
|
||||
return parseInt(handleId.split('::')[1], 10);
|
||||
}
|
||||
|
||||
export function encodeProxyHandleRef(handleId) {
|
||||
export function encodeProxyHandleRef(handleId: string): string {
|
||||
return encodeURIComponent(String(handleId || ''));
|
||||
}
|
||||
|
||||
export function decodeProxyHandleRef(encoded) {
|
||||
export function decodeProxyHandleRef(encoded: string): string {
|
||||
try {
|
||||
return decodeURIComponent(String(encoded || ''));
|
||||
} catch {
|
||||
@@ -29,7 +30,7 @@ export function decodeProxyHandleRef(encoded) {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGroupProxyHandle(handleId) {
|
||||
export function parseGroupProxyHandle(handleId: string) {
|
||||
const text = String(handleId || '');
|
||||
if (!text.startsWith('group-proxy::')) return null;
|
||||
const parts = text.split('::');
|
||||
@@ -42,12 +43,12 @@ export function parseGroupProxyHandle(handleId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getConnectionHandleType(handleId) {
|
||||
export function getConnectionHandleType(handleId: string): string {
|
||||
const proxy = parseGroupProxyHandle(handleId);
|
||||
return proxy?.type || getHandleType(handleId);
|
||||
}
|
||||
|
||||
export function getResolvedHandleRef(nodeId, handleId) {
|
||||
export function getResolvedHandleRef(nodeId: string, handleId: string) {
|
||||
const proxy = parseGroupProxyHandle(handleId);
|
||||
return {
|
||||
nodeId: proxy?.nodeId || nodeId,
|
||||
@@ -56,7 +57,7 @@ export function getResolvedHandleRef(nodeId, handleId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeInputSpecForHandle(node, handleId) {
|
||||
export function getNodeInputSpecForHandle(node: TonoNode, handleId: string): InputSpec | null {
|
||||
const definition = node?.data?.definition;
|
||||
if (!definition?.input) return null;
|
||||
const inputName = getInputName(handleId);
|
||||
@@ -67,14 +68,14 @@ export function getNodeInputSpecForHandle(node, handleId) {
|
||||
|
||||
// ── Type compatibility ────────────────────────────────────────────────
|
||||
|
||||
export function outputTypeCanConnectToTarget(outputType, targetSpecOrType, outputAcceptedTypes = []) {
|
||||
export function outputTypeCanConnectToTarget(outputType: string, targetSpecOrType: InputSpec | string, outputAcceptedTypes: string[] = []) {
|
||||
if (socketSpecAcceptsType(outputType, targetSpecOrType)) {
|
||||
return true;
|
||||
}
|
||||
// Polymorphic output: the output socket declares it can also produce the target type
|
||||
if (outputAcceptedTypes.length > 0) {
|
||||
const targetType = Array.isArray(targetSpecOrType) ? targetSpecOrType[0] : targetSpecOrType;
|
||||
if (outputAcceptedTypes.includes(targetType)) return true;
|
||||
if (outputAcceptedTypes.includes(targetType as string)) return true;
|
||||
}
|
||||
return outputType === 'ANNOTATION_SOURCE'
|
||||
&& !socketSpecAcceptsType('ANNOTATION_SOURCE', targetSpecOrType)
|
||||
@@ -84,7 +85,7 @@ export function outputTypeCanConnectToTarget(outputType, targetSpecOrType, outpu
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveOutputTypeForTarget(outputType, targetSpecOrType) {
|
||||
export function resolveOutputTypeForTarget(outputType: string, targetSpecOrType: InputSpec | string): string {
|
||||
if (outputType !== 'ANNOTATION_SOURCE') {
|
||||
return outputType;
|
||||
}
|
||||
@@ -104,11 +105,18 @@ export function resolveOutputTypeForTarget(outputType, targetSpecOrType) {
|
||||
// Extracted from the isValidConnection useCallback so it can be unit-tested
|
||||
// without a ReactFlow context. Pass a `getNodeFn` that mirrors reactFlow.getNode.
|
||||
|
||||
export function checkConnectionValid(connection, getNodeFn) {
|
||||
interface ConnectionParams {
|
||||
source: string;
|
||||
sourceHandle: string;
|
||||
target: string;
|
||||
targetHandle: string;
|
||||
}
|
||||
|
||||
export function checkConnectionValid(connection: ConnectionParams, getNodeFn: (id: string) => TonoNode | undefined) {
|
||||
const srcType = getConnectionHandleType(connection.sourceHandle);
|
||||
const resolvedTarget = getResolvedHandleRef(connection.target, connection.targetHandle);
|
||||
const targetNode = getNodeFn(resolvedTarget.nodeId);
|
||||
const targetSpec = getNodeInputSpecForHandle(targetNode, resolvedTarget.handleId) || resolvedTarget.type;
|
||||
const targetSpec = (targetNode ? getNodeInputSpecForHandle(targetNode, resolvedTarget.handleId) : null) || resolvedTarget.type;
|
||||
if (socketSpecAcceptsType(srcType, targetSpec)) return true;
|
||||
// Polymorphic output: check if the source output declares it can produce the target type
|
||||
const srcProxy = parseGroupProxyHandle(connection.sourceHandle);
|
||||
@@ -117,6 +125,7 @@ export function checkConnectionValid(connection, getNodeFn) {
|
||||
const srcNode = getNodeFn(srcNodeId);
|
||||
const srcSlot = getOutputSlot(srcHandleId);
|
||||
const srcAcceptedTypes = srcNode?.data?.definition?.output_accepted_types?.[srcSlot] || [];
|
||||
const targetType = Array.isArray(targetSpec) ? targetSpec[0] : targetSpec;
|
||||
const targetTypeRaw = Array.isArray(targetSpec) ? targetSpec[0] : targetSpec;
|
||||
const targetType = Array.isArray(targetTypeRaw) ? targetTypeRaw[0] : targetTypeRaw;
|
||||
return Array.isArray(srcAcceptedTypes) && srcAcceptedTypes.includes(targetType);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { extractWorkflow } from './pngMetadata.ts';
|
||||
import type { SerializedWorkflow } from './types';
|
||||
|
||||
const DEFAULT_WORKFLOW_CANDIDATES = [
|
||||
interface WorkflowCandidate {
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WORKFLOW_CANDIDATES: WorkflowCandidate[] = [
|
||||
{ path: '/default-workflow.json', type: 'json' },
|
||||
{ path: '/default-workflow.png', type: 'png' },
|
||||
];
|
||||
|
||||
async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
|
||||
type FetchImpl = typeof fetch;
|
||||
type ExtractWorkflowFn = (blob: Blob) => Promise<SerializedWorkflow | null>;
|
||||
|
||||
async function loadCandidate(candidate: WorkflowCandidate, fetchImpl: FetchImpl, extractWorkflowFn: ExtractWorkflowFn): Promise<SerializedWorkflow | null> {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(candidate.path, { cache: 'no-store' });
|
||||
@@ -39,9 +48,9 @@ async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
|
||||
}
|
||||
|
||||
export async function loadDefaultWorkflowAsset({
|
||||
fetchImpl = fetch,
|
||||
extractWorkflowFn = extractWorkflow,
|
||||
} = {}) {
|
||||
fetchImpl = fetch as FetchImpl,
|
||||
extractWorkflowFn = extractWorkflow as ExtractWorkflowFn,
|
||||
}: { fetchImpl?: FetchImpl; extractWorkflowFn?: ExtractWorkflowFn } = {}) {
|
||||
for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) {
|
||||
const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn);
|
||||
if (workflow) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts';
|
||||
import type { InputSpec, TonoNode, TonoEdge } from './types.ts';
|
||||
|
||||
const OMITTED_WIDGET_INPUTS_BY_CLASS = {
|
||||
const OMITTED_WIDGET_INPUTS_BY_CLASS: Record<string, Set<string>> = {
|
||||
View3D: new Set([
|
||||
'camera_azimuth',
|
||||
'camera_polar',
|
||||
@@ -11,15 +12,15 @@ const OMITTED_WIDGET_INPUTS_BY_CLASS = {
|
||||
]),
|
||||
};
|
||||
|
||||
function getInputName(handleId) {
|
||||
function getInputName(handleId: string): string {
|
||||
return handleId.split('::')[1];
|
||||
}
|
||||
|
||||
function getOutputSlot(handleId) {
|
||||
function getOutputSlot(handleId: string): number {
|
||||
return parseInt(handleId.split('::')[1], 10);
|
||||
}
|
||||
|
||||
function resolveExecutionEdge(edge) {
|
||||
function resolveExecutionEdge(edge: TonoEdge): TonoEdge {
|
||||
const original = edge?.data?.groupProxyOriginal;
|
||||
if (!original) return edge;
|
||||
return {
|
||||
@@ -31,8 +32,8 @@ function resolveExecutionEdge(edge) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getConnectedNodeIds(edges) {
|
||||
const connectedNodeIds = new Set();
|
||||
export function getConnectedNodeIds(edges: TonoEdge[]): Set<string> {
|
||||
const connectedNodeIds = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
const resolved = resolveExecutionEdge(edge);
|
||||
connectedNodeIds.add(resolved.source);
|
||||
@@ -41,11 +42,11 @@ export function getConnectedNodeIds(edges) {
|
||||
return connectedNodeIds;
|
||||
}
|
||||
|
||||
function isPreviewLoadNode(node) {
|
||||
function isPreviewLoadNode(node: TonoNode): boolean {
|
||||
return ['Image', 'ImageDemo'].includes(node?.data?.className);
|
||||
}
|
||||
|
||||
function hasPreviewLoadSelection(node) {
|
||||
function hasPreviewLoadSelection(node: TonoNode): boolean {
|
||||
if (node?.data?.className === 'Image') {
|
||||
return !!String(node.data?.widgetValues?.filename || '').trim();
|
||||
}
|
||||
@@ -55,7 +56,7 @@ function hasPreviewLoadSelection(node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getRunnableNodeIds(nodes, edges) {
|
||||
function getRunnableNodeIds(nodes: TonoNode[], edges: TonoEdge[]): Set<string> {
|
||||
const connectedNodeIds = getConnectedNodeIds(edges);
|
||||
|
||||
const runnableNodeIds = new Set(connectedNodeIds);
|
||||
@@ -69,9 +70,9 @@ function getRunnableNodeIds(nodes, edges) {
|
||||
return runnableNodeIds;
|
||||
}
|
||||
|
||||
export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
|
||||
export function serializeExecutionGraph(nodes: TonoNode[], edges: TonoEdge[], { excludeManualTrigger = false } = {}) {
|
||||
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
|
||||
const prompt = {};
|
||||
const prompt: Record<string, { class_type: string; inputs: Record<string, unknown> }> = {};
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!runnableNodeIds.has(node.id)) continue;
|
||||
@@ -81,11 +82,11 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
if (!definition) continue;
|
||||
if (excludeManualTrigger && definition.manual_trigger) continue;
|
||||
|
||||
const inputs = {};
|
||||
const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) };
|
||||
const inputs: Record<string, unknown> = {};
|
||||
const valueBag: Record<string, unknown> = { ...(widgetValues || {}), ...(runtimeValues || {}) };
|
||||
const omittedInputs = OMITTED_WIDGET_INPUTS_BY_CLASS[className] || null;
|
||||
|
||||
const allWidgets = {
|
||||
const allWidgets: Record<string, InputSpec> = {
|
||||
...(definition.input.required || {}),
|
||||
...(definition.input.optional || {}),
|
||||
};
|
||||
@@ -103,8 +104,8 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
.map(resolveExecutionEdge)
|
||||
.filter((edge) => edge.target === node.id);
|
||||
for (const edge of incoming) {
|
||||
const inputName = getInputName(edge.targetHandle);
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
const inputName = getInputName(edge.targetHandle!);
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle!);
|
||||
inputs[inputName] = [edge.source, outputSlot];
|
||||
}
|
||||
|
||||
@@ -114,18 +115,18 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function getAutoRunnableNodes(nodes, edges) {
|
||||
export function getAutoRunnableNodes(nodes: TonoNode[], edges: TonoEdge[]): TonoNode[] {
|
||||
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
|
||||
return nodes.filter((node) => runnableNodeIds.has(node.id));
|
||||
}
|
||||
|
||||
export function hasBlockingAutoRunInput(node, edges) {
|
||||
export function hasBlockingAutoRunInput(node: TonoNode, edges: TonoEdge[]): boolean {
|
||||
const def = node.data?.definition;
|
||||
if (!def || def.manual_trigger) return false;
|
||||
|
||||
const required = def.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type, opts] = getSpecTypeAndOptions(spec);
|
||||
const [type, opts] = getSpecTypeAndOptions(spec as InputSpec);
|
||||
const hiddenByConnectedInput = (() => {
|
||||
const raw = opts?.hide_when_input_connected;
|
||||
if (!raw) return false;
|
||||
@@ -133,7 +134,7 @@ export function hasBlockingAutoRunInput(node, edges) {
|
||||
return inputs.some((inputName) => edges.some(
|
||||
(edge) => {
|
||||
const resolved = resolveExecutionEdge(edge);
|
||||
return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
|
||||
return resolved.target === node.id && getInputName(resolved.targetHandle!) === String(inputName);
|
||||
}
|
||||
));
|
||||
})();
|
||||
@@ -144,11 +145,11 @@ export function hasBlockingAutoRunInput(node, edges) {
|
||||
if (!node.data.widgetValues?.[name]) return true;
|
||||
continue;
|
||||
}
|
||||
if (!isDataSocketSpec(spec)) continue;
|
||||
if (!isDataSocketSpec(spec as InputSpec)) continue;
|
||||
const hasEdge = edges.some(
|
||||
(edge) => {
|
||||
const resolved = resolveExecutionEdge(edge);
|
||||
return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
|
||||
return resolved.target === node.id && getInputName(resolved.targetHandle!) === name;
|
||||
}
|
||||
);
|
||||
if (!hasEdge) return true;
|
||||
|
||||
@@ -3,4 +3,4 @@ import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
|
||||
@@ -2,26 +2,36 @@ export const MARKUP_DEFAULT_SHAPE = 'arrow';
|
||||
export const MARKUP_DEFAULT_COLOR = '#ff0000';
|
||||
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
|
||||
|
||||
function clampFraction(value) {
|
||||
export interface MarkupShape {
|
||||
kind: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
width: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function clampFraction(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
export function sanitizeMarkupColor(color, fallback = MARKUP_DEFAULT_COLOR) {
|
||||
export function sanitizeMarkupColor(color: unknown, fallback: string = MARKUP_DEFAULT_COLOR): string {
|
||||
if (typeof color !== 'string') return fallback;
|
||||
const value = color.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
export function sanitizeMarkupShape(
|
||||
shape,
|
||||
fallbackShape = MARKUP_DEFAULT_SHAPE,
|
||||
fallbackColor = MARKUP_DEFAULT_COLOR,
|
||||
fallbackWidth = 3,
|
||||
) {
|
||||
shape: Partial<MarkupShape> | null | undefined,
|
||||
fallbackShape: string = MARKUP_DEFAULT_SHAPE,
|
||||
fallbackColor: string = MARKUP_DEFAULT_COLOR,
|
||||
fallbackWidth: number = 3,
|
||||
): MarkupShape | null {
|
||||
if (!shape || typeof shape !== 'object') return null;
|
||||
const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
|
||||
const kind = (shape.kind && ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind)) ? shape.kind : fallbackShape;
|
||||
const x1 = clampFraction(shape.x1);
|
||||
const y1 = clampFraction(shape.y1);
|
||||
const x2 = clampFraction(shape.x2);
|
||||
@@ -39,15 +49,15 @@ export function sanitizeMarkupShape(
|
||||
}
|
||||
|
||||
export function parseMarkupShapes(
|
||||
markupShapes,
|
||||
fallbackShape = MARKUP_DEFAULT_SHAPE,
|
||||
fallbackColor = MARKUP_DEFAULT_COLOR,
|
||||
fallbackWidth = 3,
|
||||
) {
|
||||
markupShapes: unknown,
|
||||
fallbackShape: string = MARKUP_DEFAULT_SHAPE,
|
||||
fallbackColor: string = MARKUP_DEFAULT_COLOR,
|
||||
fallbackWidth: number = 3,
|
||||
): MarkupShape[] {
|
||||
if (Array.isArray(markupShapes)) {
|
||||
return markupShapes
|
||||
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
|
||||
.filter(Boolean);
|
||||
.filter((s): s is MarkupShape => s != null);
|
||||
}
|
||||
|
||||
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
|
||||
@@ -57,13 +67,13 @@ export function parseMarkupShapes(
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
|
||||
.filter(Boolean);
|
||||
.filter((s): s is MarkupShape => s != null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getArrowGeometry(shape, imageWidth, imageHeight) {
|
||||
export function getArrowGeometry(shape: MarkupShape, imageWidth: number, imageHeight: number) {
|
||||
const x1 = shape.x1 * imageWidth;
|
||||
const y1 = shape.y1 * imageHeight;
|
||||
const x2 = shape.x2 * imageWidth;
|
||||
@@ -90,7 +100,7 @@ export function getArrowGeometry(shape, imageWidth, imageHeight) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getMarkupPreviewStrokeWidth(width, imageWidth, imageHeight) {
|
||||
export function getMarkupPreviewStrokeWidth(width: number, imageWidth: number, imageHeight: number) {
|
||||
const normalizedWidth = Math.max(1, Math.round(Number(width) || 1));
|
||||
const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0);
|
||||
const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM);
|
||||
|
||||
@@ -5,11 +5,11 @@ const FILE_ACCEPT = [
|
||||
'.ttf', '.otf', '.woff', '.woff2',
|
||||
].join(',');
|
||||
|
||||
function normalizeRelativePath(path) {
|
||||
function normalizeRelativePath(path: string) {
|
||||
return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function pickWithInput({ directory = false } = {}) {
|
||||
function pickWithInput({ directory = false } = {}): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
@@ -38,9 +38,14 @@ function pickWithInput({ directory = false } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectDirectoryEntries(handle, prefix = handle.name) {
|
||||
const entries = [];
|
||||
for await (const [name, child] of handle.entries()) {
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
async function collectDirectoryEntries(handle: FileSystemDirectoryHandle, prefix: string = handle.name): Promise<FileEntry[]> {
|
||||
const entries: FileEntry[] = [];
|
||||
for await (const [name, child] of (handle as any).entries()) {
|
||||
const relativePath = prefix ? `${prefix}/${name}` : name;
|
||||
if (child.kind === 'file') {
|
||||
const file = await child.getFile();
|
||||
@@ -56,8 +61,8 @@ async function collectDirectoryEntries(handle, prefix = handle.name) {
|
||||
|
||||
export async function pickNativeFileSelection() {
|
||||
try {
|
||||
if (typeof window.showOpenFilePicker === 'function') {
|
||||
const [handle] = await window.showOpenFilePicker({
|
||||
if (typeof (window as any).showOpenFilePicker === 'function') {
|
||||
const [handle] = await (window as any).showOpenFilePicker({
|
||||
multiple: false,
|
||||
types: [{
|
||||
description: 'Supported files',
|
||||
@@ -74,7 +79,7 @@ export async function pickNativeFileSelection() {
|
||||
entries: [{ file, relativePath: normalizeRelativePath(file.name) }],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') throw error;
|
||||
return null;
|
||||
}
|
||||
@@ -89,8 +94,8 @@ export async function pickNativeFileSelection() {
|
||||
|
||||
export async function pickNativeDirectorySelection() {
|
||||
try {
|
||||
if (typeof window.showDirectoryPicker === 'function') {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
if (typeof (window as any).showDirectoryPicker === 'function') {
|
||||
const handle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker();
|
||||
if (!handle) return null;
|
||||
const entries = await collectDirectoryEntries(handle, handle.name);
|
||||
return {
|
||||
@@ -98,14 +103,14 @@ export async function pickNativeDirectorySelection() {
|
||||
entries,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') throw error;
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = await pickWithInput({ directory: true });
|
||||
if (files.length === 0) return null;
|
||||
const entries = files.map((file) => ({
|
||||
const entries = files.map((file: File) => ({
|
||||
file,
|
||||
relativePath: normalizeRelativePath(file.webkitRelativePath || file.name),
|
||||
}));
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
import { sortNodesForParentOrder } from './nodeHierarchy.ts';
|
||||
import type { TonoNode, TonoEdge, NodeData, NodeDefsRegistry } from './types.ts';
|
||||
|
||||
export const NODE_CLIPBOARD_KIND = 'tono/node-selection';
|
||||
export const NODE_CLIPBOARD_MIME = 'application/x-tono-node-selection';
|
||||
|
||||
function cloneValue(value) {
|
||||
interface ClipboardNodeData {
|
||||
label: string;
|
||||
className: string;
|
||||
widgetValues: Record<string, unknown>;
|
||||
runtimeValues: Record<string, unknown>;
|
||||
extraData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ClipboardNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
parentId?: string;
|
||||
extent?: unknown;
|
||||
hidden?: boolean;
|
||||
style?: unknown;
|
||||
dragHandle?: string;
|
||||
data: ClipboardNodeData;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClipboardEdge {
|
||||
source: string;
|
||||
sourceHandle?: string | null;
|
||||
target: string;
|
||||
targetHandle?: string | null;
|
||||
style?: unknown;
|
||||
hidden?: boolean;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClipboardPayload {
|
||||
kind: string;
|
||||
version: number;
|
||||
nodes: ClipboardNode[];
|
||||
edges: ClipboardEdge[];
|
||||
}
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
if (value == null) return value;
|
||||
if (typeof structuredClone === 'function') {
|
||||
try {
|
||||
@@ -15,16 +58,16 @@ function cloneValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clonePlainObject(value) {
|
||||
function clonePlainObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return cloneValue(value) || {};
|
||||
return cloneValue(value as Record<string, unknown>) || {};
|
||||
}
|
||||
|
||||
function encodeProxyHandleRef(handleId) {
|
||||
function encodeProxyHandleRef(handleId: string): string {
|
||||
return encodeURIComponent(String(handleId || ''));
|
||||
}
|
||||
|
||||
function decodeProxyHandleRef(encoded) {
|
||||
function decodeProxyHandleRef(encoded: string): string {
|
||||
try {
|
||||
return decodeURIComponent(String(encoded || ''));
|
||||
} catch {
|
||||
@@ -32,7 +75,7 @@ function decodeProxyHandleRef(encoded) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseGroupProxyHandle(handleId) {
|
||||
function parseGroupProxyHandle(handleId: string) {
|
||||
const text = String(handleId || '');
|
||||
if (!text.startsWith('group-proxy::')) return null;
|
||||
const parts = text.split('::');
|
||||
@@ -45,41 +88,42 @@ function parseGroupProxyHandle(handleId) {
|
||||
};
|
||||
}
|
||||
|
||||
function hasOwn(obj, key) {
|
||||
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function remapNodeId(value, idMap) {
|
||||
function remapNodeId(value: string | null | undefined, idMap: Map<string, string>): string | null | undefined {
|
||||
if (value == null) return value;
|
||||
return idMap.get(String(value)) || String(value);
|
||||
}
|
||||
|
||||
function remapGroupProxyHandle(handleId, idMap) {
|
||||
function remapGroupProxyHandle(handleId: string | null | undefined, idMap: Map<string, string>): string | null | undefined {
|
||||
if (!handleId) return handleId;
|
||||
const proxy = parseGroupProxyHandle(handleId);
|
||||
if (!proxy) return handleId;
|
||||
return `group-proxy::${proxy.direction}::${remapNodeId(proxy.nodeId, idMap)}::${proxy.type}::${encodeProxyHandleRef(proxy.realHandle)}`;
|
||||
}
|
||||
|
||||
function remapGroupProxyDescriptors(items, idMap) {
|
||||
function remapGroupProxyDescriptors(items: unknown, idMap: Map<string, string>): unknown {
|
||||
if (!Array.isArray(items)) return items;
|
||||
return items.map((item) => {
|
||||
return items.map((item: Record<string, unknown>) => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
const nextItem = { ...item };
|
||||
if (typeof nextItem.key === 'string') {
|
||||
const separator = nextItem.key.indexOf('::');
|
||||
const separator = (nextItem.key as string).indexOf('::');
|
||||
if (separator !== -1) {
|
||||
const handleId = nextItem.key.slice(separator + 2);
|
||||
nextItem.key = `${remapNodeId(nextItem.key.slice(0, separator), idMap)}::${remapGroupProxyHandle(handleId, idMap)}`;
|
||||
const handleId = (nextItem.key as string).slice(separator + 2);
|
||||
nextItem.key = `${remapNodeId((nextItem.key as string).slice(0, separator), idMap)}::${remapGroupProxyHandle(handleId, idMap)}`;
|
||||
}
|
||||
}
|
||||
if (typeof nextItem.handleId === 'string') {
|
||||
nextItem.handleId = remapGroupProxyHandle(nextItem.handleId, idMap);
|
||||
nextItem.handleId = remapGroupProxyHandle(nextItem.handleId as string, idMap);
|
||||
}
|
||||
return nextItem;
|
||||
});
|
||||
}
|
||||
|
||||
function remapClipboardExtraData(extraData, idMap) {
|
||||
function remapClipboardExtraData(extraData: unknown, idMap: Map<string, string>): Record<string, unknown> {
|
||||
const nextExtraData = clonePlainObject(extraData);
|
||||
if (Array.isArray(nextExtraData.proxyInputs)) {
|
||||
nextExtraData.proxyInputs = remapGroupProxyDescriptors(nextExtraData.proxyInputs, idMap);
|
||||
@@ -90,34 +134,35 @@ function remapClipboardExtraData(extraData, idMap) {
|
||||
return nextExtraData;
|
||||
}
|
||||
|
||||
function remapClipboardEdgeData(data, idMap) {
|
||||
function remapClipboardEdgeData(data: unknown, idMap: Map<string, string>): unknown {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return cloneValue(data);
|
||||
|
||||
const nextData = cloneValue(data);
|
||||
const nextData = cloneValue(data) as Record<string, unknown>;
|
||||
if (hasOwn(nextData, 'groupInternalHiddenBy')) {
|
||||
nextData.groupInternalHiddenBy = remapNodeId(nextData.groupInternalHiddenBy, idMap);
|
||||
nextData.groupInternalHiddenBy = remapNodeId(nextData.groupInternalHiddenBy as string, idMap);
|
||||
}
|
||||
if (hasOwn(nextData, 'groupProxyOwner')) {
|
||||
nextData.groupProxyOwner = remapNodeId(nextData.groupProxyOwner, idMap);
|
||||
nextData.groupProxyOwner = remapNodeId(nextData.groupProxyOwner as string, idMap);
|
||||
}
|
||||
|
||||
const original = nextData.groupProxyOriginal;
|
||||
if (original && typeof original === 'object' && !Array.isArray(original)) {
|
||||
if (hasOwn(original, 'source')) original.source = remapNodeId(original.source, idMap);
|
||||
if (hasOwn(original, 'target')) original.target = remapNodeId(original.target, idMap);
|
||||
if (hasOwn(original, 'sourceHandle')) {
|
||||
original.sourceHandle = remapGroupProxyHandle(original.sourceHandle, idMap);
|
||||
const orig = original as Record<string, unknown>;
|
||||
if (hasOwn(orig, 'source')) orig.source = remapNodeId(orig.source as string, idMap);
|
||||
if (hasOwn(orig, 'target')) orig.target = remapNodeId(orig.target as string, idMap);
|
||||
if (hasOwn(orig, 'sourceHandle')) {
|
||||
orig.sourceHandle = remapGroupProxyHandle(orig.sourceHandle as string, idMap);
|
||||
}
|
||||
if (hasOwn(original, 'targetHandle')) {
|
||||
original.targetHandle = remapGroupProxyHandle(original.targetHandle, idMap);
|
||||
if (hasOwn(orig, 'targetHandle')) {
|
||||
orig.targetHandle = remapGroupProxyHandle(orig.targetHandle as string, idMap);
|
||||
}
|
||||
}
|
||||
|
||||
return nextData;
|
||||
}
|
||||
|
||||
function collectSelectedNodeIds(nodes, nodeIds) {
|
||||
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
|
||||
function collectSelectedNodeIds(nodes: TonoNode[], nodeIds: string[]): Set<string> {
|
||||
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id: string) => String(id)));
|
||||
if (selectedIdSet.size === 0) return selectedIdSet;
|
||||
|
||||
let changed = true;
|
||||
@@ -135,7 +180,7 @@ function collectSelectedNodeIds(nodes, nodeIds) {
|
||||
return selectedIdSet;
|
||||
}
|
||||
|
||||
function extractExtraData(data) {
|
||||
function extractExtraData(data: NodeData): Record<string, unknown> {
|
||||
const source = data || {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([key]) => ![
|
||||
@@ -156,11 +201,11 @@ function extractExtraData(data) {
|
||||
}
|
||||
|
||||
export function buildNodeClipboardPayloadForIds(
|
||||
nodes,
|
||||
edges,
|
||||
nodeIds,
|
||||
nodes: TonoNode[],
|
||||
edges: TonoEdge[],
|
||||
nodeIds: string[],
|
||||
{ includeIncomingExternalEdges = false } = {},
|
||||
) {
|
||||
): ClipboardPayload | null {
|
||||
const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
|
||||
const selectedNodes = Array.isArray(nodes)
|
||||
? nodes.filter((node) => selectedIdSet.has(String(node.id)))
|
||||
@@ -177,7 +222,7 @@ export function buildNodeClipboardPayloadForIds(
|
||||
))
|
||||
: [];
|
||||
|
||||
const snapDim = (v) => {
|
||||
const snapDim = (v: number | undefined) => {
|
||||
const n = Math.round(Number(v));
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
};
|
||||
@@ -224,7 +269,7 @@ export function buildNodeClipboardPayloadForIds(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeClipboardPayload(nodes, edges) {
|
||||
export function buildNodeClipboardPayload(nodes: TonoNode[], edges: TonoEdge[]) {
|
||||
const selectedNodes = Array.isArray(nodes)
|
||||
? nodes.filter((node) => node?.selected)
|
||||
: [];
|
||||
@@ -233,7 +278,7 @@ export function buildNodeClipboardPayload(nodes, edges) {
|
||||
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
|
||||
}
|
||||
|
||||
export function parseNodeClipboardPayload(text) {
|
||||
export function parseNodeClipboardPayload(text: string): ClipboardPayload | null {
|
||||
if (typeof text !== 'string' || !text.trim()) return null;
|
||||
|
||||
try {
|
||||
@@ -247,10 +292,10 @@ export function parseNodeClipboardPayload(text) {
|
||||
}
|
||||
|
||||
export function instantiateNodeClipboardPayload(
|
||||
payload,
|
||||
defs = {},
|
||||
nextNodeId = 1,
|
||||
offset = { x: 40, y: 40 },
|
||||
payload: ClipboardPayload | null,
|
||||
defs: NodeDefsRegistry = {},
|
||||
nextNodeId: number = 1,
|
||||
offset: { x: number; y: number } = { x: 40, y: 40 },
|
||||
{ keepExternalSources = false } = {},
|
||||
) {
|
||||
if (!payload || !Array.isArray(payload.nodes) || payload.nodes.length === 0) {
|
||||
@@ -260,11 +305,11 @@ export function instantiateNodeClipboardPayload(
|
||||
const idMap = new Map();
|
||||
let currentId = Number(nextNodeId) || 1;
|
||||
|
||||
payload.nodes.forEach((node) => {
|
||||
payload.nodes.forEach((node: ClipboardNode) => {
|
||||
idMap.set(String(node.id), String(currentId++));
|
||||
});
|
||||
|
||||
const nodes = sortNodesForParentOrder(payload.nodes.map((node) => {
|
||||
const nodes = sortNodesForParentOrder(payload.nodes.map((node: ClipboardNode) => {
|
||||
const newId = idMap.get(String(node.id));
|
||||
const className = node.data?.className || '';
|
||||
const definition = className ? defs[className] || null : null;
|
||||
@@ -305,11 +350,11 @@ export function instantiateNodeClipboardPayload(
|
||||
}));
|
||||
|
||||
const edges = payload.edges
|
||||
.filter((edge) => (
|
||||
.filter((edge: ClipboardEdge) => (
|
||||
idMap.has(String(edge.target))
|
||||
&& (idMap.has(String(edge.source)) || keepExternalSources)
|
||||
))
|
||||
.map((edge, index) => {
|
||||
.map((edge: ClipboardEdge, index: number) => {
|
||||
const source = idMap.get(String(edge.source)) || String(edge.source);
|
||||
const target = idMap.get(String(edge.target));
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.ts';
|
||||
import type { InputSpec, NodeDefinition } from './types';
|
||||
|
||||
export function getDefaultWidgetValue(spec) {
|
||||
export function getDefaultWidgetValue(spec: InputSpec) {
|
||||
const [type, opts] = getSpecTypeAndOptions(spec);
|
||||
if (isDataSocketSpec(spec)) return undefined;
|
||||
if (type === 'BUTTON') return undefined;
|
||||
@@ -13,8 +14,8 @@ export function getDefaultWidgetValue(spec) {
|
||||
return opts?.default ?? '';
|
||||
}
|
||||
|
||||
export function buildDefaultWidgetValues(definition) {
|
||||
const widgetValues = {};
|
||||
export function buildDefaultWidgetValues(definition: NodeDefinition | null | undefined) {
|
||||
const widgetValues: Record<string, unknown> = {};
|
||||
const required = definition?.input?.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const value = getDefaultWidgetValue(spec);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import type { WidgetDescriptor } from './types.ts';
|
||||
|
||||
interface DataInputDescriptor {
|
||||
name: string;
|
||||
type: string | string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function formatUiLabel(text: unknown): string {
|
||||
return String(text ?? '')
|
||||
.replace(/_/g, ' ')
|
||||
@@ -13,7 +21,7 @@ function normalizeInputNames(raw: unknown): string[] {
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
export function getWidgetCombinedInputName(widget, dataInputByName) {
|
||||
export function getWidgetCombinedInputName(widget: WidgetDescriptor | null | undefined, dataInputByName: Map<string, DataInputDescriptor> | null | undefined) {
|
||||
const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0];
|
||||
if (explicitInputName && dataInputByName?.has(explicitInputName)) {
|
||||
return explicitInputName;
|
||||
@@ -34,8 +42,8 @@ export function getWidgetCombinedInputName(widget, dataInputByName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCombinedInputNameByWidgetName(widgets, dataInputs) {
|
||||
const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input]));
|
||||
export function buildCombinedInputNameByWidgetName(widgets: WidgetDescriptor[], dataInputs: DataInputDescriptor[]) {
|
||||
const dataInputByName = new Map((dataInputs || []).map((input: DataInputDescriptor) => [input.name, input]));
|
||||
const combinedInputNameByWidgetName = new Map();
|
||||
|
||||
for (const widget of widgets || []) {
|
||||
|
||||
@@ -17,7 +17,7 @@ for (let i = 0; i < 256; i++) {
|
||||
crcTable[i] = c;
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
function crc32(bytes: Uint8Array) {
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
||||
@@ -29,7 +29,7 @@ function crc32(bytes) {
|
||||
|
||||
const PNG_SIG = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
function isPng(data) {
|
||||
function isPng(data: Uint8Array) {
|
||||
if (data.length < 8) return false;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (data[i] !== PNG_SIG[i]) return false;
|
||||
@@ -37,17 +37,17 @@ function isPng(data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function chunkType(data, offset) {
|
||||
function chunkType(data: Uint8Array, offset: number) {
|
||||
return String.fromCharCode(
|
||||
data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
|
||||
);
|
||||
}
|
||||
|
||||
function readUint32(data, offset) {
|
||||
function readUint32(data: Uint8Array, offset: number) {
|
||||
return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0);
|
||||
}
|
||||
|
||||
function buildChunk(type, payload) {
|
||||
function buildChunk(type: string, payload: Uint8Array) {
|
||||
const encoder = new TextEncoder();
|
||||
const typeBytes = encoder.encode(type);
|
||||
const forCrc = new Uint8Array(4 + payload.length);
|
||||
@@ -63,7 +63,7 @@ function buildChunk(type, payload) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseTextChunk(type, chunkData) {
|
||||
function parseTextChunk(type: string, chunkData: Uint8Array) {
|
||||
const decoder = new TextDecoder();
|
||||
const keywordEnd = chunkData.indexOf(0);
|
||||
if (keywordEnd === -1) return null;
|
||||
@@ -99,7 +99,7 @@ function parseTextChunk(type, chunkData) {
|
||||
* Embed a workflow object into a PNG blob as an iTXt chunk.
|
||||
* Returns a new Blob with the metadata inserted before IEND.
|
||||
*/
|
||||
export async function embedWorkflow(pngBlob, workflow) {
|
||||
export async function embedWorkflow(pngBlob: Blob, workflow: Record<string, unknown>) {
|
||||
const data = new Uint8Array(await pngBlob.arrayBuffer());
|
||||
if (!isPng(data)) throw new Error('Not a valid PNG file');
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function embedWorkflow(pngBlob, workflow) {
|
||||
* Extract the workflow object from a PNG blob's iTXt chunks.
|
||||
* Returns the parsed object, or null if no "workflow" key is found.
|
||||
*/
|
||||
export async function extractWorkflow(pngBlob) {
|
||||
export async function extractWorkflow(pngBlob: Blob) {
|
||||
const data = new Uint8Array(await pngBlob.arrayBuffer());
|
||||
if (!isPng(data)) return null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface InputOptions {
|
||||
text_input?: boolean;
|
||||
color_picker?: boolean;
|
||||
colormap_stops?: boolean;
|
||||
top_socket_input?: string | string[];
|
||||
set_widgets?: Record<string, unknown>;
|
||||
show_when_source_type?: Record<string, string[]>;
|
||||
show_when_widget_value?: Record<string, unknown[]>;
|
||||
@@ -45,6 +46,7 @@ export interface NodeDefinition {
|
||||
output: string[];
|
||||
output_name: string[];
|
||||
output_paths?: string[];
|
||||
output_accepted_types?: string[][];
|
||||
category: string;
|
||||
manual_trigger?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, type MutableRefObject } from 'react';
|
||||
import type { TonoNode, TonoEdge } from './types';
|
||||
|
||||
interface Snapshot {
|
||||
nodes: TonoNode[];
|
||||
edges: TonoEdge[];
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-based undo/redo for nodes + edges.
|
||||
@@ -7,10 +14,10 @@ import { useRef, useCallback } from 'react';
|
||||
* Call `undo` / `redo` to restore.
|
||||
*/
|
||||
export default function useUndoRedo({ maxHistory = 50 } = {}) {
|
||||
const pastRef = useRef([]);
|
||||
const futureRef = useRef([]);
|
||||
const pastRef = useRef<Snapshot[]>([]);
|
||||
const futureRef = useRef<Snapshot[]>([]);
|
||||
|
||||
const pushSnapshot = useCallback((nodes, edges, nextId) => {
|
||||
const pushSnapshot = useCallback((nodes: TonoNode[], edges: TonoEdge[], nextId: number) => {
|
||||
pastRef.current = [
|
||||
...pastRef.current.slice(-(maxHistory - 1)),
|
||||
{
|
||||
@@ -22,7 +29,7 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
|
||||
futureRef.current = [];
|
||||
}, [maxHistory]);
|
||||
|
||||
const undo = useCallback((setNodes, setEdges, nextIdRef, getNodes, getEdges) => {
|
||||
const undo = useCallback((setNodes: (n: TonoNode[]) => void, setEdges: (e: TonoEdge[]) => void, nextIdRef: MutableRefObject<number>, getNodes: () => TonoNode[], getEdges: () => TonoEdge[]) => {
|
||||
if (pastRef.current.length === 0) return false;
|
||||
futureRef.current = [
|
||||
...futureRef.current,
|
||||
@@ -32,14 +39,14 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
|
||||
nextId: nextIdRef.current,
|
||||
},
|
||||
];
|
||||
const snapshot = pastRef.current.pop();
|
||||
const snapshot = pastRef.current.pop()!;
|
||||
setNodes(snapshot.nodes);
|
||||
setEdges(snapshot.edges);
|
||||
nextIdRef.current = snapshot.nextId;
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const redo = useCallback((setNodes, setEdges, nextIdRef, getNodes, getEdges) => {
|
||||
const redo = useCallback((setNodes: (n: TonoNode[]) => void, setEdges: (e: TonoEdge[]) => void, nextIdRef: MutableRefObject<number>, getNodes: () => TonoNode[], getEdges: () => TonoEdge[]) => {
|
||||
if (futureRef.current.length === 0) return false;
|
||||
pastRef.current = [
|
||||
...pastRef.current,
|
||||
@@ -49,7 +56,7 @@ export default function useUndoRedo({ maxHistory = 50 } = {}) {
|
||||
nextId: nextIdRef.current,
|
||||
},
|
||||
];
|
||||
const snapshot = futureRef.current.pop();
|
||||
const snapshot = futureRef.current.pop()!;
|
||||
setNodes(snapshot.nodes);
|
||||
setEdges(snapshot.edges);
|
||||
nextIdRef.current = snapshot.nextId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const SI_PREFIX_MULTIPLIERS = {
|
||||
const SI_PREFIX_MULTIPLIERS: Record<string, number> = {
|
||||
Y: 1e24, Z: 1e21, E: 1e18, P: 1e15, T: 1e12,
|
||||
G: 1e9, M: 1e6, k: 1e3,
|
||||
m: 1e-3, u: 1e-6, µ: 1e-6, n: 1e-9, p: 1e-12,
|
||||
@@ -16,7 +16,7 @@ const PREFIXABLE_UNITS = new Set([
|
||||
* Returns null if the string does not start with a valid number.
|
||||
* The numeric value is scaled to the base SI unit via the prefix.
|
||||
*/
|
||||
export function parseNumberWithUnit(text) {
|
||||
export function parseNumberWithUnit(text: unknown) {
|
||||
const s = String(text ?? '').trim();
|
||||
if (!s) return { numeric: 0, unit: '' };
|
||||
|
||||
@@ -60,7 +60,7 @@ const SI_PREFIXES = [
|
||||
{ exp: 24, prefix: 'Y' },
|
||||
];
|
||||
|
||||
const SUPERSCRIPT_DIGITS = {
|
||||
const SUPERSCRIPT_DIGITS: Record<string, string> = {
|
||||
'-': '⁻',
|
||||
'0': '⁰',
|
||||
'1': '¹',
|
||||
@@ -74,7 +74,7 @@ const SUPERSCRIPT_DIGITS = {
|
||||
'9': '⁹',
|
||||
};
|
||||
|
||||
export function formatNumericCell(value) {
|
||||
export function formatNumericCell(value: unknown) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
@@ -87,18 +87,18 @@ export function formatNumericCell(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toSuperscript(text) {
|
||||
function toSuperscript(text: string | number) {
|
||||
return String(text)
|
||||
.split('')
|
||||
.map((char) => SUPERSCRIPT_DIGITS[char] || char)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function formatDisplayUnit(unit) {
|
||||
export function formatDisplayUnit(unit: unknown) {
|
||||
return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent));
|
||||
}
|
||||
|
||||
function parsePrefixableUnit(unit) {
|
||||
function parsePrefixableUnit(unit: unknown) {
|
||||
const text = String(unit ?? '').trim();
|
||||
if (!text) return null;
|
||||
|
||||
@@ -113,11 +113,11 @@ function parsePrefixableUnit(unit) {
|
||||
return { baseUnit: text, power: 1 };
|
||||
}
|
||||
|
||||
function formatPrefixedUnit(baseUnit, prefix, power) {
|
||||
function formatPrefixedUnit(baseUnit: string, prefix: string, power: number) {
|
||||
return power === 1 ? `${prefix}${baseUnit}` : `${prefix}${baseUnit}${toSuperscript(power)}`;
|
||||
}
|
||||
|
||||
function choosePrefixExponent(value, power) {
|
||||
function choosePrefixExponent(value: number, power: number) {
|
||||
const abs = Math.abs(value);
|
||||
const candidates = SI_PREFIXES.map(({ exp, prefix }) => {
|
||||
const scaled = value / (10 ** (exp * power));
|
||||
@@ -147,7 +147,7 @@ function choosePrefixExponent(value, power) {
|
||||
* and prefixed unit label to use for a whole axis.
|
||||
* All tick values should be divided by `scale` before display, and `unitLabel` shown once.
|
||||
*/
|
||||
export function getAxisScale(representativeValue, unit) {
|
||||
export function getAxisScale(representativeValue: unknown, unit: string) {
|
||||
if (!unit || typeof representativeValue !== 'number' || !Number.isFinite(representativeValue) || representativeValue === 0) {
|
||||
return { scale: 1, unitLabel: unit || '' };
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export function getAxisScale(representativeValue, unit) {
|
||||
return { scale: representativeValue / scaled, unitLabel: unitText };
|
||||
}
|
||||
|
||||
export function applySIPrefix(value, unit) {
|
||||
export function applySIPrefix(value: unknown, unit: unknown) {
|
||||
const formattedUnit = formatDisplayUnit(unit);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return { valueText: formatNumericCell(value), unitText: formattedUnit };
|
||||
@@ -178,17 +178,17 @@ export function applySIPrefix(value, unit) {
|
||||
};
|
||||
}
|
||||
|
||||
function getCompanionUnitColumn(column, row) {
|
||||
function getCompanionUnitColumn(column: unknown, row: unknown) {
|
||||
if (!row || typeof row !== 'object' || typeof column !== 'string' || column === 'unit') {
|
||||
return null;
|
||||
}
|
||||
const unitColumn = `${column}_unit`;
|
||||
return typeof row?.[unitColumn] === 'string' ? unitColumn : null;
|
||||
return typeof (row as Record<string, unknown>)?.[unitColumn] === 'string' ? unitColumn : null;
|
||||
}
|
||||
|
||||
export function getTableColumns(rows) {
|
||||
const columns = [];
|
||||
const hiddenColumns = new Set();
|
||||
export function getTableColumns(rows: Array<Record<string, unknown>> | null | undefined) {
|
||||
const columns: string[] = [];
|
||||
const hiddenColumns = new Set<string>();
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
@@ -208,7 +208,7 @@ export function getTableColumns(rows) {
|
||||
return columns.filter((column) => !hiddenColumns.has(column));
|
||||
}
|
||||
|
||||
export function formatTableRowCell(row, column) {
|
||||
export function formatTableRowCell(row: Record<string, unknown>, column: string) {
|
||||
const companionUnitColumn = getCompanionUnitColumn(column, row);
|
||||
if (companionUnitColumn) {
|
||||
const formatted = applySIPrefix(row?.[column], row?.[companionUnitColumn]);
|
||||
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,22 +1,21 @@
|
||||
import { toBlob } from 'html-to-image';
|
||||
import { CANVAS_COLORS } from './constants.ts';
|
||||
import { CAPTURE_SELECTOR as linePlotSelector } from './LinePlotOverlay';
|
||||
import { CAPTURE_SELECTOR as thresholdSelector } from './ThresholdHistogram';
|
||||
import { CAPTURE_SELECTOR as csSelector } from './CrossSectionOverlay';
|
||||
import { CAPTURE_SELECTOR as cropSelector } from './CropBoxOverlay';
|
||||
import { CAPTURE_SELECTOR as markupSelector } from './MarkupOverlay';
|
||||
import { CAPTURE_SELECTOR as angleSelector } from './AngleMeasureOverlay';
|
||||
|
||||
// Assembled from each overlay component's CAPTURE_SELECTOR export.
|
||||
// To register a new overlay: export CAPTURE_SELECTOR from its file and add
|
||||
// an import + entry here. Missing entries produce corrupt ~68-byte PNG output.
|
||||
// Mirror the CAPTURE_SELECTOR values from each overlay component.
|
||||
// Duplicated here so workflowCapture.ts stays a plain .ts file that
|
||||
// Node can run without a JSX transform (needed for tests).
|
||||
// To register a new overlay, add its selector string here AND export
|
||||
// CAPTURE_SELECTOR from the overlay component file.
|
||||
export const OVERLAY_CAPTURE_SELECTORS = [
|
||||
...new Set([linePlotSelector, thresholdSelector, csSelector, cropSelector, markupSelector, angleSelector]),
|
||||
'.lineplot-overlay', // LinePlotOverlay + ThresholdHistogram
|
||||
'.cs-overlay', // CrossSectionOverlay
|
||||
'.crop-overlay', // CropBoxOverlay
|
||||
'.markup-overlay', // MarkupOverlay
|
||||
'.angle-overlay', // AngleMeasureOverlay
|
||||
];
|
||||
|
||||
function encodeBase64(bytes) {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
function encodeBase64(bytes: Uint8Array) {
|
||||
if (typeof (globalThis as any).Buffer !== 'undefined') {
|
||||
return (globalThis as any).Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
@@ -26,13 +25,13 @@ function encodeBase64(bytes) {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
async function blobToDataUrl(blob: Blob | null) {
|
||||
if (!blob) return null;
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`;
|
||||
}
|
||||
|
||||
function getElementSize(el) {
|
||||
function getElementSize(el: HTMLElement) {
|
||||
const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 };
|
||||
return {
|
||||
width: Math.max(1, Math.round(el.clientWidth || rect.width || 0)),
|
||||
@@ -40,7 +39,7 @@ function getElementSize(el) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForImageElement(img) {
|
||||
export async function waitForImageElement(img: HTMLImageElement) {
|
||||
if (img.complete && img.naturalWidth > 0) return;
|
||||
if (typeof img.decode === 'function') {
|
||||
try {
|
||||
@@ -50,7 +49,7 @@ export async function waitForImageElement(img) {
|
||||
// Fall back to load/error listeners below.
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
img.removeEventListener('load', done);
|
||||
img.removeEventListener('error', done);
|
||||
@@ -61,7 +60,7 @@ export async function waitForImageElement(img) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCaptureImageDataUrl(img) {
|
||||
export async function getCaptureImageDataUrl(img: HTMLImageElement) {
|
||||
const src = img.currentSrc || img.src;
|
||||
if (!src) return null;
|
||||
if (!src.startsWith('data:')) return src;
|
||||
@@ -85,9 +84,9 @@ export async function getCaptureImageDataUrl(img) {
|
||||
}
|
||||
|
||||
export function createCapturePlaceholder(
|
||||
el,
|
||||
dataUrl,
|
||||
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) } = {},
|
||||
el: HTMLElement,
|
||||
dataUrl: string,
|
||||
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) }: { stretch?: boolean; documentRef?: Document; getComputedStyleFn?: typeof getComputedStyle } = {},
|
||||
) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyleFn(el);
|
||||
@@ -110,7 +109,7 @@ export function createCapturePlaceholder(
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
async function renderCanvasToDataUrl(canvas) {
|
||||
async function renderCanvasToDataUrl(canvas: HTMLCanvasElement) {
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
@@ -118,7 +117,7 @@ async function renderCanvasToDataUrl(canvas) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderElementToDataUrl(el, toBlobImpl) {
|
||||
async function renderElementToDataUrl(el: HTMLElement, toBlobImpl: typeof toBlob) {
|
||||
const { width, height } = getElementSize(el);
|
||||
const blob = await toBlobImpl(el, {
|
||||
width,
|
||||
@@ -133,7 +132,7 @@ async function renderElementToDataUrl(el, toBlobImpl) {
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPlaceholderFn, stretch) {
|
||||
async function replaceElementsWithPlaceholders(elements: HTMLElement[], renderDataUrl: (el: HTMLElement) => Promise<string | null>, createPlaceholderFn: (el: HTMLElement, dataUrl: string, opts: { stretch: boolean }) => HTMLElement, stretch: boolean) {
|
||||
const restorers = [];
|
||||
|
||||
for (const el of elements) {
|
||||
@@ -153,21 +152,33 @@ async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPl
|
||||
return restorers;
|
||||
}
|
||||
|
||||
function defaultQueryAll(root, selector) {
|
||||
return Array.from(root.querySelectorAll(selector));
|
||||
function defaultQueryAll(root: HTMLElement, selector: string): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll(selector)) as HTMLElement[];
|
||||
}
|
||||
|
||||
function defaultNextFrame() {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
export async function captureViewportBlob(viewportEl, options, deps = {}) {
|
||||
interface CaptureViewportDeps {
|
||||
queryAll?: (root: HTMLElement, selector: string) => HTMLElement[];
|
||||
toBlobImpl?: typeof toBlob;
|
||||
waitForImageElement?: (img: HTMLImageElement) => Promise<void>;
|
||||
renderImageToDataUrl?: (img: HTMLImageElement) => Promise<string | null>;
|
||||
renderCanvasToDataUrl?: (canvas: HTMLCanvasElement) => Promise<string | null>;
|
||||
renderOverlayToDataUrl?: (el: HTMLElement) => Promise<string | null>;
|
||||
createPlaceholder?: (el: HTMLElement, dataUrl: string, opts: { stretch: boolean }) => HTMLElement;
|
||||
nextFrame?: () => Promise<void>;
|
||||
overlaySelectors?: string[];
|
||||
}
|
||||
|
||||
export async function captureViewportBlob(viewportEl: HTMLElement, options: Record<string, unknown>, deps: CaptureViewportDeps = {}) {
|
||||
const queryAll = deps.queryAll ?? defaultQueryAll;
|
||||
const toBlobImpl = deps.toBlobImpl ?? toBlob;
|
||||
const waitForImage = deps.waitForImageElement ?? waitForImageElement;
|
||||
const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl;
|
||||
const renderCanvas = deps.renderCanvasToDataUrl ?? renderCanvasToDataUrl;
|
||||
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el) => renderElementToDataUrl(el, toBlobImpl));
|
||||
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el: HTMLElement) => renderElementToDataUrl(el, toBlobImpl));
|
||||
const createPlaceholderFn = deps.createPlaceholder ?? createCapturePlaceholder;
|
||||
const nextFrame = deps.nextFrame ?? defaultNextFrame;
|
||||
const overlaySelectors = deps.overlaySelectors ?? OVERLAY_CAPTURE_SELECTORS;
|
||||
@@ -183,13 +194,13 @@ export async function captureViewportBlob(viewportEl, options, deps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const images = queryAll(viewportEl, 'img');
|
||||
const images = queryAll(viewportEl, 'img') as HTMLImageElement[];
|
||||
await Promise.all(images.map(waitForImage));
|
||||
|
||||
try {
|
||||
restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true));
|
||||
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage, createPlaceholderFn, false));
|
||||
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas, createPlaceholderFn, true));
|
||||
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage as (el: HTMLElement) => Promise<string | null>, createPlaceholderFn, false));
|
||||
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas as (el: HTMLElement) => Promise<string | null>, createPlaceholderFn, true));
|
||||
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { sortNodesForParentOrder } from './nodeHierarchy.ts';
|
||||
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.ts';
|
||||
import type { NodeDefsRegistry, NodeDefinition, InputSpec, SerializedWorkflow } from './types';
|
||||
|
||||
function mergeDefinition(nodeData, defs) {
|
||||
function mergeDefinition(nodeData: Record<string, unknown> | undefined, defs: NodeDefsRegistry): NodeDefinition | null {
|
||||
const savedData = nodeData || {};
|
||||
const registryDefinition = savedData.className ? defs[savedData.className] : null;
|
||||
const registryDefinition = savedData.className ? defs[savedData.className as string] : null;
|
||||
return registryDefinition || null;
|
||||
}
|
||||
|
||||
function getSocketType(inputDef) {
|
||||
function getSocketType(inputDef: InputSpec | undefined) {
|
||||
if (!inputDef) return null;
|
||||
const [type] = Array.isArray(inputDef) ? inputDef : [inputDef];
|
||||
return Array.isArray(type) ? type[0] : type;
|
||||
}
|
||||
|
||||
function getInputEntries(definition) {
|
||||
function getInputEntries(definition: NodeDefinition | null) {
|
||||
return [
|
||||
...Object.entries(definition?.input?.required || {}),
|
||||
...Object.entries(definition?.input?.optional || {}),
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeWidgetValues(widgetValues, definition, preservedPaths) {
|
||||
function sanitizeWidgetValues(widgetValues: Record<string, unknown> | undefined, definition: NodeDefinition | null, preservedPaths: Set<unknown> | undefined) {
|
||||
const nextValues = { ...(widgetValues || {}) };
|
||||
|
||||
getInputEntries(definition).forEach(([inputName, inputDef]) => {
|
||||
getInputEntries(definition).forEach(([inputName, inputDef]: [string, InputSpec]) => {
|
||||
const type = getSocketType(inputDef);
|
||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||
if (preservedPaths && preservedPaths.has(nextValues[inputName])) return;
|
||||
@@ -34,7 +35,7 @@ function sanitizeWidgetValues(widgetValues, definition, preservedPaths) {
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
export function hydrateWorkflowState(data, defs = {}, { preservedPaths } = {}) {
|
||||
export function hydrateWorkflowState(data: SerializedWorkflow | null | undefined, defs: NodeDefsRegistry = {}, { preservedPaths }: { preservedPaths?: Set<unknown> } = {}) {
|
||||
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import * as api from './api.ts';
|
||||
import type { SerializedWorkflow, NodeDefsRegistry, InputSpec } from './types.ts';
|
||||
|
||||
const MAX_PACKED_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
@@ -20,7 +21,7 @@ function arrayBufferToBase64(buffer) {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToUint8Array(b64) {
|
||||
function base64ToUint8Array(b64: string) {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
@@ -29,21 +30,21 @@ function base64ToUint8Array(b64) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getInputType(spec) {
|
||||
function getInputType(spec: InputSpec | null) {
|
||||
if (!spec) return null;
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
return Array.isArray(type) ? type[0] : type;
|
||||
}
|
||||
|
||||
function filenameFromPath(path) {
|
||||
return String(path).split('/').pop();
|
||||
function filenameFromPath(path: string): string {
|
||||
return String(path).split('/').pop() || path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the relative path portion from a session:// URI.
|
||||
* e.g. "session://uploads/myfolder/scan.ibw" → "myfolder/scan.ibw"
|
||||
*/
|
||||
function sessionRelativePath(path) {
|
||||
function sessionRelativePath(path: string) {
|
||||
const prefix = 'session://uploads/';
|
||||
if (path.startsWith(prefix)) return path.slice(prefix.length);
|
||||
return filenameFromPath(path);
|
||||
@@ -59,9 +60,9 @@ function sessionRelativePath(path) {
|
||||
* @param {function} [onProgress] - Optional (packed, total) callback
|
||||
* @returns {object} workflowData with packedFiles added
|
||||
*/
|
||||
export async function packWorkflow(workflowData, nodeDefs, onProgress) {
|
||||
export async function packWorkflow(workflowData: SerializedWorkflow, nodeDefs: NodeDefsRegistry, onProgress?: (packed: number, total: number) => void) {
|
||||
// 1. Collect FILE_PICKER paths only (skip FOLDER_PICKER)
|
||||
const filePaths = new Set();
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
for (const node of workflowData.nodes) {
|
||||
const className = node.data?.className;
|
||||
@@ -87,7 +88,7 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
|
||||
}
|
||||
|
||||
// 3. Fetch each file and encode
|
||||
const packedFiles = {};
|
||||
const packedFiles: Record<string, { filename: string; data: string }> = {};
|
||||
let totalBytes = 0;
|
||||
let packed = 0;
|
||||
const total = filePaths.size;
|
||||
@@ -108,7 +109,7 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
|
||||
data: arrayBufferToBase64(buffer),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.message.includes('limit')) throw err;
|
||||
if ((err as Error).message.includes('limit')) throw err;
|
||||
// File may not exist (e.g. cleared path) — skip
|
||||
}
|
||||
packed++;
|
||||
@@ -134,14 +135,14 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
|
||||
* @param {object} workflowData - Workflow data potentially containing packedFiles
|
||||
* @returns {{ workflow: object, restoredPaths: Set<string> }}
|
||||
*/
|
||||
export async function unpackWorkflow(workflowData) {
|
||||
export async function unpackWorkflow(workflowData: SerializedWorkflow) {
|
||||
const packedFiles = workflowData.packedFiles;
|
||||
if (!packedFiles || Object.keys(packedFiles).length === 0) {
|
||||
return { workflow: workflowData, restoredPaths: new Set() };
|
||||
}
|
||||
|
||||
const pathMap = {}; // oldPath → newSessionPath
|
||||
const restoredPaths = new Set();
|
||||
const pathMap: Record<string, string> = {}; // oldPath → newSessionPath
|
||||
const restoredPaths = new Set<string>();
|
||||
|
||||
// 1. Upload each packed file
|
||||
for (const [origPath, entry] of Object.entries(packedFiles)) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.ts';
|
||||
import type { TonoNode, TonoEdge, SerializedWorkflow } from './types';
|
||||
|
||||
export function serializeWorkflowState(nodes, edges) {
|
||||
const compactObject = (value) => {
|
||||
export function serializeWorkflowState(nodes: TonoNode[], edges: TonoEdge[]) {
|
||||
const compactObject = (value: Record<string, unknown> | null | undefined) => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const entries = Object.entries(value);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||
};
|
||||
const getExtraData = (data) => compactObject(Object.fromEntries(
|
||||
Object.entries(data || {}).filter(([key]) => ![
|
||||
const getExtraData = (data: Record<string, unknown>) => compactObject(Object.fromEntries(
|
||||
Object.entries(data || {}).filter(([key]: [string, unknown]) => ![
|
||||
'label',
|
||||
'className',
|
||||
'widgetValues',
|
||||
@@ -22,11 +23,11 @@ export function serializeWorkflowState(nodes, edges) {
|
||||
'warning',
|
||||
].includes(key))
|
||||
));
|
||||
const getRuntimeValues = (node) => compactObject(
|
||||
const getRuntimeValues = (node: TonoNode) => compactObject(
|
||||
sanitizeRuntimeValuesForPersistence(node.data?.className, node.data?.runtimeValues),
|
||||
);
|
||||
|
||||
const snapDim = (v) => {
|
||||
const snapDim = (v: unknown) => {
|
||||
const n = Math.round(Number(v));
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user