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 { useState, useEffect, KeyboardEvent } from "react";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import { useTuioStore } from "../../stores/tuioStore";
import TangibleForm from "./TangibleForm"; import TangibleForm from "./TangibleForm";
import type { TangibleConfig, TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types"; import type { TangibleConfig, TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
import type { ConstellationState } from "../../types/timeline"; import type { ConstellationState } from "../../types/timeline";
@ -34,6 +35,11 @@ const EditTangibleInline = ({
onSave, onSave,
onCancel, onCancel,
}: Props) => { }: 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 [name, setName] = useState("");
const [mode, setMode] = useState<TangibleMode>("filter"); const [mode, setMode] = useState<TangibleMode>("filter");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@ -128,6 +134,7 @@ const EditTangibleInline = ({
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
states={states} states={states}
suggestedHardwareId={suggestedHardwareId}
onNameChange={setName} onNameChange={setName}
onModeChange={setMode} onModeChange={setMode}
onDescriptionChange={setDescription} onDescriptionChange={setDescription}

View file

@ -1,4 +1,5 @@
import { useState, useRef, KeyboardEvent } from "react"; import { useState, useRef, KeyboardEvent } from "react";
import { useTuioStore } from "../../stores/tuioStore";
import TangibleForm from "./TangibleForm"; import TangibleForm from "./TangibleForm";
import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types"; import type { TangibleMode, LabelConfig, FilterConfig, NodeTypeConfig, EdgeTypeConfig } from "../../types";
import type { ConstellationState } from "../../types/timeline"; import type { ConstellationState } from "../../types/timeline";
@ -19,6 +20,11 @@ interface Props {
} }
const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: 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 [name, setName] = useState("");
const [hardwareId, setHardwareId] = useState(""); const [hardwareId, setHardwareId] = useState("");
const [mode, setMode] = useState<TangibleMode>("filter"); const [mode, setMode] = useState<TangibleMode>("filter");
@ -115,6 +121,7 @@ const QuickAddTangibleForm = ({ labels, nodeTypes, edgeTypes, states, onAdd }: P
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
states={states} states={states}
suggestedHardwareId={suggestedHardwareId}
onNameChange={setName} onNameChange={setName}
onHardwareIdChange={setHardwareId} onHardwareIdChange={setHardwareId}
onModeChange={setMode} onModeChange={setMode}

View file

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

View file

@ -16,6 +16,7 @@ interface Props {
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
states: ConstellationState[]; states: ConstellationState[];
suggestedHardwareId?: string;
onNameChange: (value: string) => void; onNameChange: (value: string) => void;
onModeChange: (value: TangibleMode) => void; onModeChange: (value: TangibleMode) => void;
onDescriptionChange: (value: string) => void; onDescriptionChange: (value: string) => void;
@ -39,6 +40,7 @@ const TangibleForm = ({
nodeTypes, nodeTypes,
edgeTypes, edgeTypes,
states, states,
suggestedHardwareId,
onNameChange, onNameChange,
onModeChange, onModeChange,
onDescriptionChange, onDescriptionChange,
@ -63,9 +65,21 @@ const TangibleForm = ({
</div> </div>
<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) Hardware ID (optional)
</label> </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 <input
type="text" type="text"
value={hardwareId} 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 { useSettingsStore } from '../stores/settingsStore';
import { useGraphStore } from '../stores/graphStore'; import { useGraphStore } from '../stores/graphStore';
import { useTimelineStore } from '../stores/timelineStore'; 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 { TuioTangibleInfo } from '../lib/tuio/types';
import type { TangibleConfig } from '../types'; import type { TangibleConfig } from '../types';
import { migrateTangibleConfig } from '../utils/tangibleMigration'; import { migrateTangibleConfig } from '../utils/tangibleMigration';
@ -20,54 +19,14 @@ import { migrateTangibleConfig } from '../utils/tangibleMigration';
* - State mode: Switches timeline state * - State mode: Switches timeline state
*/ */
export function useTuioIntegration() { export function useTuioIntegration() {
const clientRef = useRef<TuioClientManager | null>(null);
const { presentationMode } = useSettingsStore(); const { presentationMode } = useSettingsStore();
const { websocketUrl, protocolVersion } = useTuioStore();
useEffect(() => { // Use the shared TUIO connection hook with presentation mode callbacks
// Only connect in presentation mode useTuioConnection(presentationMode, {
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(
{
onTangibleAdd: handleTangibleAdd, onTangibleAdd: handleTangibleAdd,
onTangibleUpdate: handleTangibleUpdate, onTangibleUpdate: handleTangibleUpdate,
onTangibleRemove: handleTangibleRemove, 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]);
} }
/** /**