refactor: optimize replay preview state management and cleanup editor UI components
Build and Release / release (push) Successful in 1m0s
Build and Release / release (push) Successful in 1m0s
This commit is contained in:
@@ -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
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user