Zwei Nachbesserungen am Cockpit-Modus (noch Teil von 0.2.1.9): - Im Cockpit werden IMMER alle vier Kacheln gezeigt (Chat/Editor/Desktop/ Vorschau) statt erst bei Code-Projekten — Editor/Desktop/Vorschau als Platzhalter mit Status-Untertitel. Vorher wirkten sie "verschwunden". - Der "⤢ Uebersicht"-Button hing im Chat genau ueber dem Abbrechen-Button der "ARIA denkt"-Leiste. Jetzt sitzt er im Navigations-Header links (nur sichtbar im Cockpit + Fokus), via neuem cockpitNav-Signal-Singleton. In-Content-Button entfernt. tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
233 lines
8.1 KiB
TypeScript
233 lines
8.1 KiB
TypeScript
/**
|
|
* 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<Record<TileId, string>>;
|
|
}
|
|
|
|
const ANIM = { duration: 260 };
|
|
const MIN_SCALE = 0.15;
|
|
const MAX_SCALE = 4;
|
|
|
|
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
|
|
const { width: vw, height: vh } = useWindowDimensions();
|
|
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('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 <ChatTile />;
|
|
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
|
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
|
|
case 'preview': return <PreviewTile />;
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View style={styles.root}>
|
|
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
|
|
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
|
|
<GestureDetector gesture={canvasGesture}>
|
|
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
|
|
{visibleTiles.map((id) => (
|
|
<Tile
|
|
key={id}
|
|
id={id}
|
|
rect={toLocal(TILE_RECTS[id], bounds)}
|
|
subtitle={subtitles?.[id]}
|
|
onFocus={setFocusedTileId}
|
|
/>
|
|
))}
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</View>
|
|
|
|
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
|
|
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
|
|
{visibleTiles.map((id) => (
|
|
<View
|
|
key={id}
|
|
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
|
|
>
|
|
{renderContent(id)}
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Steuerung — der „⤢ Übersicht"-Button sitzt im Header (cockpitNav),
|
|
nicht hier im Chat-Inhalt. */}
|
|
{!focusedTileId && (
|
|
<View style={styles.hintBar} pointerEvents="none">
|
|
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
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;
|