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