From 5ffa472da0729dc40cd2d4c668f0f1b24866734f Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 4 Oct 2026 16:43:36 +0200 Subject: [PATCH] fix(mobile): Story-Editor lint-sauber (Gesten-Fabrik ausserhalb der Komponente) Co-Authored-By: Claude Opus 5.5 --- .../story-editor/EditableOverlay.tsx | 145 +++++++++--------- .../src/components/story-editor/GifPicker.tsx | 2 +- .../components/story-editor/MentionPicker.tsx | 2 +- .../story-editor/OverlayContent.tsx | 1 + 4 files changed, 77 insertions(+), 73 deletions(-) diff --git a/mobile/src/components/story-editor/EditableOverlay.tsx b/mobile/src/components/story-editor/EditableOverlay.tsx index 11587a8..c54342a 100644 --- a/mobile/src/components/story-editor/EditableOverlay.tsx +++ b/mobile/src/components/story-editor/EditableOverlay.tsx @@ -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); + 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 ( + + + + + + ); +} + +/** + * 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) { - 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 ( - - - - - - ); + 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), + }); } diff --git a/mobile/src/components/story-editor/GifPicker.tsx b/mobile/src/components/story-editor/GifPicker.tsx index a7ed0fb..2a1b901 100644 --- a/mobile/src/components/story-editor/GifPicker.tsx +++ b/mobile/src/components/story-editor/GifPicker.tsx @@ -75,7 +75,7 @@ export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; on }} renderItem={({ item }) => ( onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF"> - + GIF )} ListFooterComponent={isFetching ? : null} diff --git a/mobile/src/components/story-editor/MentionPicker.tsx b/mobile/src/components/story-editor/MentionPicker.tsx index d8ffb71..f21db02 100644 --- a/mobile/src/components/story-editor/MentionPicker.tsx +++ b/mobile/src/components/story-editor/MentionPicker.tsx @@ -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"; diff --git a/mobile/src/components/story-editor/OverlayContent.tsx b/mobile/src/components/story-editor/OverlayContent.tsx index ba39227..28646dc 100644 --- a/mobile/src/components/story-editor/OverlayContent.tsx +++ b/mobile/src/components/story-editor/OverlayContent.tsx @@ -35,6 +35,7 @@ export const OverlayContent = memo(function OverlayContent({ autoplay contentFit="cover" accessibilityLabel="GIF" + alt="GIF" /> ); }