Add TUIO connection in tangible config dialog and hardware ID suggestion

Features:
- TUIO connection now starts when tangible config dialog is open
- Connection closes when dialog is closed
- Last detected tangible ID is suggested for Hardware ID field
- "Use: [ID]" link appears next to Hardware ID field when tangible detected
- Clicking the link auto-fills the Hardware ID field

Technical Changes:
- Created useTuioConnection hook for shared TUIO connection management
- Refactored useTuioIntegration to use new useTuioConnection hook
- Added suggestedHardwareId prop to TangibleForm component
- Updated QuickAddTangibleForm to get and pass suggested ID
- Updated EditTangibleInline to get and pass suggested ID
- TangibleConfig modal now uses useTuioConnection when isOpen is true

UI Improvements:
- Hardware ID suggestion link styled like auto-zoom toggle
- Shows truncated ID if longer than 8 characters (e.g., "Use: abc123...")
- Full ID shown in tooltip on hover

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-19 20:27:50 +01:00
parent 1c56066f47
commit 2ffebb9eb7
6 changed files with 119 additions and 52 deletions

View file

@ -1,5 +1,6 @@
import { useState, useEffect, KeyboardEvent } from "react";
import SaveIcon from "@mui/icons-material/Save";
import { useTuioStore } from "../../stores/tuioStore";
import TangibleForm from "./TangibleForm";
import type { TangibleConfig, TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
import type { ConstellationState } from "../../types/timeline";
@ -34,6 +35,11 @@ const EditTangibleInline = ({
onSave,
onCancel,
}: Props) => {
// Get the last detected tangible ID from TUIO store
const activeTangibles = useTuioStore((state) => state.activeTangibles);
const suggestedHardwareId = activeTangibles.size > 0
? Array.from(activeTangibles.keys()).pop()
: undefined;
const [name, setName] = useState("");
const [mode, setMode] = useState<TangibleMode>("filter");
const [description, setDescription] = useState("");
@ -128,6 +134,7 @@ const EditTangibleInline = ({
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={states}
suggestedHardwareId={suggestedHardwareId}
onNameChange={setName}
onModeChange={setMode}
onDescriptionChange={setDescription}

View file

@ -1,4 +1,5 @@
import { useState, useRef, KeyboardEvent } from "react";
import { useTuioStore } from "../../stores/tuioStore";
import TangibleForm from "./TangibleForm";
import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
import type { ConstellationState } from "../../types/timeline";
@ -19,6 +20,11 @@ interface Props {
}
const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: Props) => {
// Get the last detected tangible ID from TUIO store
const activeTangibles = useTuioStore((state) => state.activeTangibles);
const suggestedHardwareId = activeTangibles.size > 0
? Array.from(activeTangibles.keys()).pop()
: undefined;
const [name, setName] = useState("");
const [hardwareId, setHardwareId] = useState("");
const [mode, setMode] = useState<TangibleMode>("filter");
@ -115,6 +121,7 @@ const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: P
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
states={states}
suggestedHardwareId={suggestedHardwareId}
onNameChange={setName}
onHardwareIdChange={setHardwareId}
onModeChange={setMode}

View file

@ -3,6 +3,7 @@ import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useConfirm } from '../../hooks/useConfirm';
import { useToastStore } from '../../stores/toastStore';
import { useTimelineStore } from '../../stores/timelineStore';
import { useTuioConnection } from '../../hooks/useTuioConnection';
import QuickAddTangibleForm from './QuickAddTangibleForm';
import TangibleManagementList from './TangibleManagementList';
import EditTangibleInline from './EditTangibleInline';
@ -19,6 +20,9 @@ const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Prop
const { confirm, ConfirmDialogComponent } = useConfirm();
const { showToast } = useToastStore();
// Connect to TUIO when dialog is open
useTuioConnection(isOpen);
const [editingTangible, setEditingTangible] = useState<TangibleConfigType | null>(null);
// Get all available states for state mode

View file

@ -16,6 +16,7 @@ interface Props {
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
states: ConstellationState[];
suggestedHardwareId?: string;
onNameChange: (value: string) => void;
onModeChange: (value: TangibleMode) => void;
onDescriptionChange: (value: string) => void;
@ -39,6 +40,7 @@ const TangibleForm = ({
nodeTypes,
edgeTypes,
states,
suggestedHardwareId,
onNameChange,
onModeChange,
onDescriptionChange,
@ -63,9 +65,21 @@ const TangibleForm = ({
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-700">
Hardware ID (optional)
</label>
{suggestedHardwareId && (
<button
type="button"
onClick={() => onHardwareIdChange(suggestedHardwareId)}
className="text-xs text-blue-600 hover:text-blue-700 underline focus:outline-none"
title={`Use detected ID: ${suggestedHardwareId}`}
>
Use: {suggestedHardwareId.length > 8 ? suggestedHardwareId.substring(0, 8) + '...' : suggestedHardwareId}
</button>
)}
</div>
<input
type="text"
value={hardwareId}

View file

@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import { useTuioStore } from '../stores/tuioStore';
import { TuioClientManager } from '../lib/tuio/tuioClient';
import type { TuioTangibleInfo } from '../lib/tuio/types';
/**
* Hook to manage TUIO client connection lifecycle
*
* @param shouldConnect - Whether to connect to TUIO server
* @param onTangibleAdd - Optional callback when tangible is added
* @param onTangibleUpdate - Optional callback when tangible is updated
* @param onTangibleRemove - Optional callback when tangible is removed
*/
export function useTuioConnection(
shouldConnect: boolean,
callbacks?: {
onTangibleAdd?: (hardwareId: string, info: TuioTangibleInfo) => void;
onTangibleUpdate?: (hardwareId: string, info: TuioTangibleInfo) => void;
onTangibleRemove?: (hardwareId: string) => void;
}
) {
const clientRef = useRef<TuioClientManager | null>(null);
const { websocketUrl, protocolVersion } = useTuioStore();
useEffect(() => {
if (!shouldConnect) {
// Disconnect if we should not be connected
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
useTuioStore.getState().clearActiveTangibles();
}
return;
}
// Create TUIO client
const client = new TuioClientManager(
{
onTangibleAdd: (hardwareId: string, info: TuioTangibleInfo) => {
useTuioStore.getState().addActiveTangible(hardwareId, info);
callbacks?.onTangibleAdd?.(hardwareId, info);
},
onTangibleUpdate: (hardwareId: string, info: TuioTangibleInfo) => {
useTuioStore.getState().updateActiveTangible(hardwareId, info);
callbacks?.onTangibleUpdate?.(hardwareId, info);
},
onTangibleRemove: (hardwareId: string) => {
useTuioStore.getState().removeActiveTangible(hardwareId);
callbacks?.onTangibleRemove?.(hardwareId);
},
onConnectionChange: (connected, error) => {
useTuioStore.getState().setConnectionState(connected, error);
},
},
protocolVersion
);
clientRef.current = client;
// Connect to TUIO server
client
.connect(websocketUrl)
.catch(() => {
// Connection errors are handled by onConnectionChange callback
});
// Cleanup on unmount or when shouldConnect changes
return () => {
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
useTuioStore.getState().clearActiveTangibles();
}
};
}, [shouldConnect, websocketUrl, protocolVersion, callbacks]);
}

View file

@ -1,9 +1,8 @@
import { useEffect, useRef } from 'react';
import { useTuioStore } from '../stores/tuioStore';
import { useSettingsStore } from '../stores/settingsStore';
import { useGraphStore } from '../stores/graphStore';
import { useTimelineStore } from '../stores/timelineStore';
import { TuioClientManager } from '../lib/tuio/tuioClient';
import { useTuioStore } from '../stores/tuioStore';
import { useTuioConnection } from './useTuioConnection';
import type { TuioTangibleInfo } from '../lib/tuio/types';
import type { TangibleConfig } from '../types';
import { migrateTangibleConfig } from '../utils/tangibleMigration';
@ -20,54 +19,14 @@ import { migrateTangibleConfig } from '../utils/tangibleMigration';
* - State mode: Switches timeline state
*/
export function useTuioIntegration() {
const clientRef = useRef<TuioClientManager | null>(null);
const { presentationMode } = useSettingsStore();
const { websocketUrl, protocolVersion } = useTuioStore();
useEffect(() => {
// Only connect in presentation mode
if (!presentationMode) {
// Disconnect if we're leaving presentation mode
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
useTuioStore.getState().clearActiveTangibles();
}
return;
}
// Create TUIO client if in presentation mode
const client = new TuioClientManager(
{
// Use the shared TUIO connection hook with presentation mode callbacks
useTuioConnection(presentationMode, {
onTangibleAdd: handleTangibleAdd,
onTangibleUpdate: handleTangibleUpdate,
onTangibleRemove: handleTangibleRemove,
onConnectionChange: (connected, error) => {
useTuioStore.getState().setConnectionState(connected, error);
},
},
protocolVersion
);
clientRef.current = client;
// Connect to TUIO server
client
.connect(websocketUrl)
.catch(() => {
// Connection errors are handled by onConnectionChange callback
});
// Cleanup on unmount or when presentation mode changes
return () => {
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
useTuioStore.getState().clearActiveTangibles();
}
};
}, [presentationMode, websocketUrl, protocolVersion]);
}
/**