refactor: optimize replay preview state management and cleanup editor UI components
Build and Release / release (push) Successful in 1m0s

This commit is contained in:
taDuc
2026-05-28 18:24:04 +07:00
parent 0dbe26fd4e
commit b94f5f44cb
4 changed files with 214 additions and 73 deletions
-10
View File
@@ -15,7 +15,6 @@ import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel";
import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel";
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
import { ApiError } from "@/uhm/api/http"; import { ApiError } from "@/uhm/api/http";
import { fetchCurrentUser } from "@/uhm/api/auth"; import { fetchCurrentUser } from "@/uhm/api/auth";
@@ -2781,15 +2780,6 @@ function EditorPageContent() {
isGeoSearching={isGeoSearching} isGeoSearching={isGeoSearching}
onImportGeoFromSearch={handleImportGeoFromSearch} onImportGeoFromSearch={handleImportGeoFromSearch}
/> />
<ImageOverlayPanel
overlay={imageOverlay}
onPickImage={handlePickImageOverlay}
onPasteImage={handlePasteImageOverlay}
keyboardEnabled={imageOverlayKeyboardEnabled}
onKeyboardEnabledChange={setImageOverlayKeyboardEnabled}
onOpacityChange={handleImageOverlayOpacityChange}
onRemove={handleRemoveImageOverlay}
/>
<GeometryBindingPanel <GeometryBindingPanel
geometries={geometryChoices} geometries={geometryChoices}
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null} selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
+140 -5
View File
@@ -9,6 +9,12 @@ import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
import type { MapHandle } from "@/uhm/components/Map"; import type { MapHandle } from "@/uhm/components/Map";
import { useRef, useMemo, useCallback } from "react"; import { useRef, useMemo, useCallback } from "react";
import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction"; import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction";
import PresentPlaceSearch, {
type HistoricalGeometryFocusPayload,
type PresentPlaceSelection,
} from "@/uhm/components/editor/PresentPlaceSearch";
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
import type { FeatureCollection } from "@/uhm/types/geo";
import { import {
type BackgroundLayerId, type BackgroundLayerId,
type BackgroundLayerVisibility, type BackgroundLayerVisibility,
@@ -53,6 +59,7 @@ export default function Page() {
const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle"); const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle");
const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null); const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null);
const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null); const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null);
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
const [searchTimelineYear, setSearchTimelineYear] = useState(timelineYear); const [searchTimelineYear, setSearchTimelineYear] = useState(timelineYear);
useEffect(() => { useEffect(() => {
@@ -102,19 +109,22 @@ export default function Page() {
return null; return null;
}, [replays, selectedFeatureIds]); }, [replays, selectedFeatureIds]);
const getMapInstance = useCallback(() => mapHandleRef.current?.getMap() || null, []);
const handleSelectReplayStep = useCallback((stageId: number | null, stepIndex: number | null) => {
setSelectedReplayStageId(stageId);
setSelectedReplayStepIndex(stepIndex);
}, []);
const replayPreview = useReplayPreview({ const replayPreview = useReplayPreview({
replay: activeReplay?.replay || null, replay: activeReplay?.replay || null,
draft: renderDraft, draft: renderDraft,
getMapInstance: () => mapHandleRef.current?.getMap() || null, getMapInstance,
initialTimelineYear: timelineDraftYear, initialTimelineYear: timelineDraftYear,
initialTimelineFilterEnabled: false, initialTimelineFilterEnabled: false,
initialMapViewState: null, initialMapViewState: null,
selectedStageId: selectedReplayStageId, selectedStageId: selectedReplayStageId,
selectedStepIndex: selectedReplayStepIndex, selectedStepIndex: selectedReplayStepIndex,
onSelectStep: (stageId, stepIndex) => { onSelectStep: handleSelectReplayStep,
setSelectedReplayStageId(stageId);
setSelectedReplayStepIndex(stepIndex);
},
}); });
const { const {
@@ -205,8 +215,60 @@ export default function Page() {
const handleExitReplay = useCallback(() => { const handleExitReplay = useCallback(() => {
setReplayMode("idle"); setReplayMode("idle");
replayPreview.resetPreview(); replayPreview.resetPreview();
setFocusedPresentPlace(null);
}, [replayPreview]); }, [replayPreview]);
const handleFocusPresentPlace = useCallback((place: PresentPlaceSelection) => {
setFocusedPresentPlace(place);
const map = mapHandleRef.current?.getMap();
if (map) {
const currentZoom = map.getZoom();
map.flyTo({
center: [place.lng, place.lat],
zoom: Math.max(currentZoom, 13.5),
});
}
}, []);
const clearPresentPlaceFocus = useCallback(() => {
setFocusedPresentPlace(null);
}, []);
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
setFocusedPresentPlace(null);
const map = mapHandleRef.current?.getMap();
if (map && payload.geometry?.draw_geometry) {
const fc: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
id: payload.geometry.id,
},
geometry: payload.geometry.draw_geometry,
},
],
};
fitMapToFeatureCollection(map, fc, 84, { duration: 1000 });
}
if (payload.geometry.time_start != null) {
handleTimelineYearChange(payload.geometry.time_start);
}
setSelectedFeatureIds([payload.geometry.id]);
const linkedEntityIds = relations.geometryEntityIds[String(payload.geometry.id)] || [];
if (linkedEntityIds.length === 1) {
selectEntity(linkedEntityIds[0], {
sourceFeatureId: payload.geometry.id,
selectGeometry: false,
});
}
}, [relations.geometryEntityIds, selectEntity, setSelectedFeatureIds]);
const filteredRenderDraft = useMemo(() => { const filteredRenderDraft = useMemo(() => {
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) { if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
return renderDraft; return renderDraft;
@@ -294,7 +356,80 @@ export default function Page() {
/> />
) : null ) : null
} }
>
<div
style={{
position: "absolute",
top: 10,
left: 18,
zIndex: 18,
display: "flex",
gap: "10px",
alignItems: "flex-start",
pointerEvents: "auto",
}}
>
<button
type="button"
onClick={() => {
window.location.href = "/user";
}}
title="Tham gia hệ thống"
aria-label="Tham gia hệ thống"
style={{
width: "46px",
height: "46px",
backgroundColor: "#1e293b",
color: "#f8fafc",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)",
flexShrink: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#334155";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#1e293b";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.1)";
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<PresentPlaceSearch
focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onClearFocus={clearPresentPlaceFocus}
style={{
position: "relative",
top: 0,
left: 0,
width: "min(392px, calc(100vw - 90px))",
}}
/> />
</div>
</PreviewMapShell>
) : ( ) : (
<div className="h-screen w-full bg-[#0b1220]" /> <div className="h-screen w-full bg-[#0b1220]" />
)} )}
@@ -35,6 +35,7 @@ type Props = {
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void; onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
onClearFocus: () => void; onClearFocus: () => void;
leftOffset?: number; leftOffset?: number;
style?: CSSProperties;
}; };
export default function PresentPlaceSearch({ export default function PresentPlaceSearch({
@@ -43,6 +44,7 @@ export default function PresentPlaceSearch({
onFocusHistoricalGeometry, onFocusHistoricalGeometry,
onClearFocus, onClearFocus,
leftOffset = 18, leftOffset = 18,
style,
}: Props) { }: Props) {
const [mode, setMode] = useState<SearchMode>("present"); const [mode, setMode] = useState<SearchMode>("present");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -284,6 +286,7 @@ export default function PresentPlaceSearch({
zIndex: 18, zIndex: 18,
width: "min(392px, calc(100vw - 36px))", width: "min(392px, calc(100vw - 36px))",
pointerEvents: "auto", pointerEvents: "auto",
...style,
}} }}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
> >
+19 -6
View File
@@ -93,6 +93,13 @@ export function useReplayPreview({
const baselineRef = useRef<PreviewBaseline | null>(null); const baselineRef = useRef<PreviewBaseline | null>(null);
const effects = useMemo(() => createReplayMapEffects(), []); const effects = useMemo(() => createReplayMapEffects(), []);
const selectedStageIdRef = useRef(selectedStageId);
const selectedStepIndexRef = useRef(selectedStepIndex);
useEffect(() => {
selectedStageIdRef.current = selectedStageId;
selectedStepIndexRef.current = selectedStepIndex;
}, [selectedStageId, selectedStepIndex]);
const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]); const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]);
useEffect(() => { useEffect(() => {
@@ -203,7 +210,6 @@ export function useReplayPreview({
}, [restorePreviewState]); }, [restorePreviewState]);
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null); const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
useEffect(() => {
controllersRef.current = { controllersRef.current = {
map: getMapInstance(), map: getMapInstance(),
draft, draft,
@@ -255,9 +261,9 @@ export function useReplayPreview({
setDialog: setDialogWithRef, setDialog: setDialogWithRef,
getDialog: () => dialogRef.current, getDialog: () => dialogRef.current,
}; };
}, [addToast, draft, effects, getMapInstance]);
const playFromIndex = useCallback(async (startIndex: number) => { const playFromIndex = useCallback(async (startIndex: number) => {
console.log("playFromIndex starting at:", startIndex, "flatSteps count:", flatSteps.length);
if (!flatSteps.length) return; if (!flatSteps.length) return;
const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex)); const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex));
resetPresentation(); resetPresentation();
@@ -271,7 +277,10 @@ export function useReplayPreview({
setIsPlaying(true); setIsPlaying(true);
for (let index = safeStartIndex; index < flatSteps.length; index += 1) { for (let index = safeStartIndex; index < flatSteps.length; index += 1) {
if (runIdRef.current !== runId) return; if (runIdRef.current !== runId) {
console.log("playFromIndex loop aborted because runId changed");
return;
}
const current = flatSteps[index]; const current = flatSteps[index];
setActiveCursor({ setActiveCursor({
@@ -282,7 +291,10 @@ export function useReplayPreview({
onSelectStep(current.stageId, current.stepIndex); onSelectStep(current.stageId, current.stepIndex);
const controllers = controllersRef.current; const controllers = controllersRef.current;
if (!controllers) return; if (!controllers) {
console.warn("playFromIndex aborted: controllersRef.current is null!");
return;
}
controllers.map = getMapInstance(); controllers.map = getMapInstance();
controllers.draft = draft; controllers.draft = draft;
@@ -322,9 +334,10 @@ export function useReplayPreview({
}, [playFromIndex]); }, [playFromIndex]);
const playFromSelection = useCallback(() => { const playFromSelection = useCallback(() => {
const selectedIndex = findReplayStepIndex(flatSteps, selectedStageId, selectedStepIndex); const selectedIndex = findReplayStepIndex(flatSteps, selectedStageIdRef.current, selectedStepIndexRef.current);
console.log("playFromSelection called: selectedIndex =", selectedIndex, "selectedStageId =", selectedStageIdRef.current, "selectedStepIndex =", selectedStepIndexRef.current);
void playFromIndex(selectedIndex >= 0 ? selectedIndex : 0); void playFromIndex(selectedIndex >= 0 ? selectedIndex : 0);
}, [flatSteps, playFromIndex, selectedStageId, selectedStepIndex]); }, [flatSteps, playFromIndex]);
return { return {
isPlaying, isPlaying,