fix(mobile): Story-Editor lint-sauber (Gesten-Fabrik ausserhalb der Komponente)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 16:43:36 +02:00
1 parent 0c448de4c1
commit 5ffa472da0
4 files changed
+77 -73

No files matched your search

@@ -1,5 +1,5 @@
import { useMemo, useRef } from "react";
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState } from "react-native";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState, type PanResponderInstance } from "react-native";
import { OverlayContent } from "./OverlayContent";
import { PositionedOverlay } from "./StoryOverlayLayer";
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
@@ -7,19 +7,20 @@ import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay }
const TAP_MAX_MOVE = 6;
const TAP_MAX_MS = 250;
type Props = {
type Callbacks = {
overlay: StoryOverlay;
rect: Rect;
mentionName?: string;
onChange: (next: StoryOverlay) => void;
onTap: () => void;
/** Ziehen beginnt/endet — der Editor blendet dann den Papierkorb ein bzw. aus. */
/** Ziehen beginnt/läuft/endet — der Editor zeigt dann den Papierkorb. */
onDragStateChange: (dragging: boolean, pageY: number) => void;
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
isOverTrash: (pageY: number) => boolean;
onDelete: () => void;
};
type Props = Callbacks & { mentionName?: string };
/**
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
@@ -30,13 +31,34 @@ type Props = {
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
*/
export function EditableOverlay({ overlay, rect, mentionName, onChange, onTap, onDragStateChange, isOverTrash, onDelete }: Props) {
// Aktuelle Props für die (einmal erzeugten) PanResponder-Handler.
const latest = useRef({ overlay, rect, onChange, onTap, onDragStateChange, isOverTrash, onDelete });
latest.current = { overlay, rect, onChange, onTap, onDragStateChange, isOverTrash, onDelete };
export function EditableOverlay({ mentionName, ...callbacks }: Props) {
// Die Gesten-Handler leben über Renders hinweg und lesen den aktuellen Stand
// erst beim Berühren aus diesem Ref (klassisches PanResponder-Muster).
const latest = useRef<Callbacks>(callbacks);
useLayoutEffect(() => {
latest.current = callbacks;
});
const getLatest = useCallback(() => latest.current, []);
// Die Fabrik legt nur Handler an; gelesen wird der Ref erst bei Berührung, nie beim Rendern.
// eslint-disable-next-line react-hooks/refs -- PanResponder-Muster, siehe createOverlayResponder
const [responder] = useState(() => createOverlayResponder(getLatest));
const gesture = useRef({
base: overlay,
return (
<PositionedOverlay overlay={callbacks.overlay} rect={callbacks.rect} interactive>
<View {...responder.panHandlers} hitSlop={12}>
<OverlayContent overlay={callbacks.overlay} baseWidth={callbacks.rect.width} mentionName={mentionName} />
</View>
</PositionedOverlay>
);
}
/**
* Gestenlogik außerhalb der Komponente: hält ihren Zustand in der Closure und
* liest Props nur in Event-Handlern (nie während des Renderns).
*/
function createOverlayResponder(getLatest: () => Callbacks): PanResponderInstance {
const g = {
base: null as StoryOverlay | null,
offsetX: 0,
offsetY: 0,
touches: 0,
@@ -45,57 +67,12 @@ export function EditableOverlay({ overlay, rect, mentionName, onChange, onTap, o
startCentroid: { x: 0, y: 0 },
startedAt: 0,
moved: 0,
});
const responder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (evt) => {
const g = gesture.current;
g.base = latest.current.overlay;
g.offsetX = 0;
g.offsetY = 0;
g.touches = 0;
g.startedAt = Date.now();
g.moved = 0;
rebase(evt, { dx: 0, dy: 0 } as PanResponderGestureState);
latest.current.onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderMove: (evt, state) => {
const g = gesture.current;
const touches = evt.nativeEvent.touches;
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
if (touches.length !== g.touches) rebase(evt, state);
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
let pinch = 1;
let rotationDelta = 0;
let dx = state.dx - g.offsetX;
let dy = state.dy - g.offsetY;
if (touches.length >= 2 && g.startDistance > 0) {
const now = touchGeometry(touches[0], touches[1]);
pinch = now.distance / g.startDistance;
rotationDelta = angleDelta(g.startAngle, now.angle);
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
}
latest.current.onChange(applyGesture(g.base, latest.current.rect, { dx, dy, pinch, rotationDelta }));
latest.current.onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderRelease: (evt) => finish(evt),
onPanResponderTerminate: (evt) => finish(evt),
}),
[]
);
};
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
const g = gesture.current;
const touches = evt.nativeEvent.touches;
g.base = latest.current.overlay;
g.base = getLatest().overlay;
g.offsetX = state.dx;
g.offsetY = state.dy;
g.touches = touches.length;
@@ -108,21 +85,47 @@ export function EditableOverlay({ overlay, rect, mentionName, onChange, onTap, o
}
function finish(evt: GestureResponderEvent) {
const g = gesture.current;
const { pageY } = evt.nativeEvent;
latest.current.onDragStateChange(false, pageY);
getLatest().onDragStateChange(false, pageY);
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
latest.current.onTap();
} else if (latest.current.isOverTrash(pageY)) {
latest.current.onDelete();
getLatest().onTap();
} else if (getLatest().isOverTrash(pageY)) {
getLatest().onDelete();
}
}
return (
<PositionedOverlay overlay={overlay} rect={rect} interactive>
<View {...responder.panHandlers} hitSlop={12}>
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={mentionName} />
</View>
</PositionedOverlay>
);
return PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (evt) => {
g.startedAt = Date.now();
g.moved = 0;
rebase(evt, { dx: 0, dy: 0 });
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderMove: (evt, state) => {
const touches = evt.nativeEvent.touches;
if (touches.length !== g.touches) rebase(evt, state);
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
let pinch = 1;
let rotationDelta = 0;
let dx = state.dx - g.offsetX;
let dy = state.dy - g.offsetY;
if (touches.length >= 2 && g.startDistance > 0) {
const now = touchGeometry(touches[0], touches[1]);
pinch = now.distance / g.startDistance;
rotationDelta = angleDelta(g.startAngle, now.angle);
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
}
const base = g.base ?? getLatest().overlay;
getLatest().onChange(applyGesture(base, getLatest().rect, { dx, dy, pinch, rotationDelta }));
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderRelease: (evt) => finish(evt),
onPanResponderTerminate: (evt) => finish(evt),
});
}
@@ -75,7 +75,7 @@ export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; on
}}
renderItem={({ item }) => (
<Pressable onPress={() => onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF">
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay />
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay alt="GIF" />
</Pressable>
)}
ListFooterComponent={isFetching ? <ActivityIndicator color="#f97316" style={{ marginVertical: 12 }} /> : null}
@@ -1,5 +1,5 @@
import { useState } from "react";
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
@@ -35,6 +35,7 @@ export const OverlayContent = memo(function OverlayContent({
autoplay
contentFit="cover"
accessibilityLabel="GIF"
alt="GIF"
/>
);
}