/** * WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus. * * Zwei Ebenen uebereinander: * 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier * wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht). * 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren, * interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln * sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar. * Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet * beim Fokuswechsel. * * Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der * "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte. * Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der * Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat. */ import React, { useEffect, useMemo, useState } from 'react'; import { BackHandler, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal, } from './layout'; import cockpitNav from '../services/cockpitNav'; import Tile from './Tile'; import { useWorkspaceLayout } from './useWorkspaceLayout'; import ChatTile from './tiles/ChatTile'; import CodeEditorTile from './tiles/CodeEditorTile'; import VncTile from './tiles/VncTile'; import PreviewTile from './tiles/PreviewTile'; interface Props { projectId: string; visibleTiles: TileId[]; subtitles?: Partial>; } const ANIM = { duration: 260 }; const MIN_SCALE = 0.15; const MAX_SCALE = 4; const WorkspaceCanvas: React.FC = ({ projectId, visibleTiles, subtitles }) => { const { width: vw, height: vh } = useWindowDimensions(); const [focusedTileId, setFocusedTileId] = useState('chat'); const visibleKey = visibleTiles.join(','); const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]); const worldW = bounds.w; const worldH = bounds.h; const { loaded: layoutLoaded, getFocus, saveFocus } = useWorkspaceLayout(projectId); // Kamera (shared values fuer 60fps auf dem UI-Thread). const scale = useSharedValue(1); const tx = useSharedValue(0); const ty = useSharedValue(0); const savedScale = useSharedValue(1); const savedTx = useSharedValue(0); const savedTy = useSharedValue(0); // Verschwindet die fokussierte Kachel aus der Sichtbarkeit → auf Chat zurueck. useEffect(() => { if (focusedTileId && !visibleTiles.includes(focusedTileId)) { setFocusedTileId('chat'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visibleKey]); // Zuletzt fokussierte Kachel pro Projekt wiederherstellen. useEffect(() => { if (!layoutLoaded) return; const stored = getFocus(); if (stored === undefined) return; if (stored === null || visibleTiles.includes(stored)) setFocusedTileId(stored); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, layoutLoaded, visibleKey]); // Fokus-Wahl persistieren. useEffect(() => { if (layoutLoaded) saveFocus(focusedTileId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusedTileId, layoutLoaded]); // Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht). useEffect(() => { const cam: Camera = focusedTileId ? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh) : overviewCamera(worldW, worldH, vw, vh); scale.value = withTiming(cam.scale, ANIM); tx.value = withTiming(cam.tx, ANIM); ty.value = withTiming(cam.ty, ANIM); savedScale.value = cam.scale; savedTx.value = cam.tx; savedTy.value = cam.ty; // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusedTileId, visibleKey, vw, vh]); // Fokus-Zustand + „zur Uebersicht"-Aktion an den Header (cockpitNav) melden, // damit der Uebersicht-Button dort sitzt (kollidiert nicht mit dem Chat). useEffect(() => { cockpitNav.setFocused(focusedTileId != null); cockpitNav.registerOverview(() => setFocusedTileId(null)); return () => { cockpitNav.registerOverview(null); cockpitNav.setFocused(false); }; }, [focusedTileId]); // Hardware-Back: im Fokus → zurueck zur Uebersicht. useEffect(() => { const onBack = () => { if (focusedTileId) { setFocusedTileId(null); return true; } return false; }; const sub = BackHandler.addEventListener('hardwareBackPress', onBack); return () => sub.remove(); }, [focusedTileId]); // Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel). const gesturesEnabled = !focusedTileId; const canvasGesture = useMemo(() => { const pinch = Gesture.Pinch() .enabled(gesturesEnabled) .onUpdate((e) => { 'worklet'; scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale)); }) .onEnd(() => { 'worklet'; savedScale.value = scale.value; }); const pan = Gesture.Pan() .enabled(gesturesEnabled) .minPointers(2) .onUpdate((e) => { 'worklet'; tx.value = savedTx.value + e.translationX; ty.value = savedTy.value + e.translationY; }) .onEnd(() => { 'worklet'; savedTx.value = tx.value; savedTy.value = ty.value; }); return Gesture.Simultaneous(pinch, pan); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gesturesEnabled]); const worldStyle = useAnimatedStyle(() => ({ transform: [ { translateX: tx.value }, { translateY: ty.value }, { scale: scale.value }, ], })); const renderContent = (id: TileId) => { switch (id) { case 'chat': return ; case 'editor': return ; case 'vnc': return ; case 'preview': return ; default: return null; } }; return ( {/* Ebene 1 — skalierte Landkarte mit Thumbnails */} {visibleTiles.map((id) => ( ))} {/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */} {visibleTiles.map((id) => ( {renderContent(id)} ))} {/* Steuerung — der „⤢ Übersicht"-Button sitzt im Header (cockpitNav), nicht hier im Chat-Inhalt. */} {!focusedTileId && ( Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben )} ); }; const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#0D0D1A' }, world: { position: 'absolute', left: 0, top: 0 }, hintBar: { position: 'absolute', bottom: 16, left: 0, right: 0, alignItems: 'center', }, hintText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(18,18,42,0.85)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 14, overflow: 'hidden', }, }); export default WorkspaceCanvas;