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:
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user