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 EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
||||
import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel";
|
||||
import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel";
|
||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||
@@ -2781,15 +2780,6 @@ function EditorPageContent() {
|
||||
isGeoSearching={isGeoSearching}
|
||||
onImportGeoFromSearch={handleImportGeoFromSearch}
|
||||
/>
|
||||
<ImageOverlayPanel
|
||||
overlay={imageOverlay}
|
||||
onPickImage={handlePickImageOverlay}
|
||||
onPasteImage={handlePasteImageOverlay}
|
||||
keyboardEnabled={imageOverlayKeyboardEnabled}
|
||||
onKeyboardEnabledChange={setImageOverlayKeyboardEnabled}
|
||||
onOpacityChange={handleImageOverlayOpacityChange}
|
||||
onRemove={handleRemoveImageOverlay}
|
||||
/>
|
||||
<GeometryBindingPanel
|
||||
geometries={geometryChoices}
|
||||
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
|
||||
|
||||
+141
-6
@@ -9,6 +9,12 @@ import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
|
||||
import type { MapHandle } from "@/uhm/components/Map";
|
||||
import { useRef, useMemo, useCallback } from "react";
|
||||
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 {
|
||||
type BackgroundLayerId,
|
||||
type BackgroundLayerVisibility,
|
||||
@@ -53,6 +59,7 @@ export default function Page() {
|
||||
const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle");
|
||||
const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null);
|
||||
const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null);
|
||||
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
|
||||
|
||||
const [searchTimelineYear, setSearchTimelineYear] = useState(timelineYear);
|
||||
useEffect(() => {
|
||||
@@ -102,19 +109,22 @@ export default function Page() {
|
||||
return null;
|
||||
}, [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({
|
||||
replay: activeReplay?.replay || null,
|
||||
draft: renderDraft,
|
||||
getMapInstance: () => mapHandleRef.current?.getMap() || null,
|
||||
getMapInstance,
|
||||
initialTimelineYear: timelineDraftYear,
|
||||
initialTimelineFilterEnabled: false,
|
||||
initialMapViewState: null,
|
||||
selectedStageId: selectedReplayStageId,
|
||||
selectedStepIndex: selectedReplayStepIndex,
|
||||
onSelectStep: (stageId, stepIndex) => {
|
||||
setSelectedReplayStageId(stageId);
|
||||
setSelectedReplayStepIndex(stepIndex);
|
||||
},
|
||||
onSelectStep: handleSelectReplayStep,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -205,8 +215,60 @@ export default function Page() {
|
||||
const handleExitReplay = useCallback(() => {
|
||||
setReplayMode("idle");
|
||||
replayPreview.resetPreview();
|
||||
setFocusedPresentPlace(null);
|
||||
}, [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(() => {
|
||||
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
|
||||
return renderDraft;
|
||||
@@ -294,7 +356,80 @@ export default function Page() {
|
||||
/>
|
||||
) : 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]" />
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
|
||||
onClearFocus: () => void;
|
||||
leftOffset?: number;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function PresentPlaceSearch({
|
||||
@@ -43,6 +44,7 @@ export default function PresentPlaceSearch({
|
||||
onFocusHistoricalGeometry,
|
||||
onClearFocus,
|
||||
leftOffset = 18,
|
||||
style,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<SearchMode>("present");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -284,6 +286,7 @@ export default function PresentPlaceSearch({
|
||||
zIndex: 18,
|
||||
width: "min(392px, calc(100vw - 36px))",
|
||||
pointerEvents: "auto",
|
||||
...style,
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -93,6 +93,13 @@ export function useReplayPreview({
|
||||
const baselineRef = useRef<PreviewBaseline | null>(null);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -203,61 +210,60 @@ export function useReplayPreview({
|
||||
}, [restorePreviewState]);
|
||||
|
||||
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
|
||||
useEffect(() => {
|
||||
controllersRef.current = {
|
||||
map: getMapInstance(),
|
||||
draft,
|
||||
effects,
|
||||
setTimelineVisible,
|
||||
setTimelineFilterEnabled,
|
||||
setLayerPanelVisible,
|
||||
setZoomPanelVisible,
|
||||
setSidebarOpen,
|
||||
onSelectWiki: (id) => {
|
||||
const nextId = String(id || "").trim();
|
||||
setActiveWikiId(nextId || null);
|
||||
},
|
||||
addToast,
|
||||
setPlaybackSpeed: (nextSpeed) => {
|
||||
const safe = Number.isFinite(nextSpeed) && nextSpeed > 0 ? nextSpeed : 1;
|
||||
playbackSpeedRef.current = safe;
|
||||
setPlaybackSpeed(safe);
|
||||
},
|
||||
onYearChange: setTimelineYear,
|
||||
showGeometries: (ids) => {
|
||||
const nextIds = normalizeIdList(ids);
|
||||
if (!nextIds.length) return;
|
||||
setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id)));
|
||||
},
|
||||
hideGeometries: (ids) => {
|
||||
const nextIds = normalizeIdList(ids);
|
||||
if (!nextIds.length) return;
|
||||
setHiddenGeometryIds((prev) => {
|
||||
const seen = new Set(prev);
|
||||
for (const id of nextIds) {
|
||||
seen.add(id);
|
||||
}
|
||||
return Array.from(seen);
|
||||
});
|
||||
},
|
||||
showOnlyGeometries: (ids) => {
|
||||
const keepIds = new Set(normalizeIdList(ids));
|
||||
if (!keepIds.size) return;
|
||||
setHiddenGeometryIds(
|
||||
draft.features
|
||||
.map((feature) => String(feature.properties.id))
|
||||
.filter((id) => !keepIds.has(id))
|
||||
);
|
||||
},
|
||||
showAllGeometries: () => {
|
||||
setHiddenGeometryIds([]);
|
||||
},
|
||||
setDialog: setDialogWithRef,
|
||||
getDialog: () => dialogRef.current,
|
||||
};
|
||||
}, [addToast, draft, effects, getMapInstance]);
|
||||
controllersRef.current = {
|
||||
map: getMapInstance(),
|
||||
draft,
|
||||
effects,
|
||||
setTimelineVisible,
|
||||
setTimelineFilterEnabled,
|
||||
setLayerPanelVisible,
|
||||
setZoomPanelVisible,
|
||||
setSidebarOpen,
|
||||
onSelectWiki: (id) => {
|
||||
const nextId = String(id || "").trim();
|
||||
setActiveWikiId(nextId || null);
|
||||
},
|
||||
addToast,
|
||||
setPlaybackSpeed: (nextSpeed) => {
|
||||
const safe = Number.isFinite(nextSpeed) && nextSpeed > 0 ? nextSpeed : 1;
|
||||
playbackSpeedRef.current = safe;
|
||||
setPlaybackSpeed(safe);
|
||||
},
|
||||
onYearChange: setTimelineYear,
|
||||
showGeometries: (ids) => {
|
||||
const nextIds = normalizeIdList(ids);
|
||||
if (!nextIds.length) return;
|
||||
setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id)));
|
||||
},
|
||||
hideGeometries: (ids) => {
|
||||
const nextIds = normalizeIdList(ids);
|
||||
if (!nextIds.length) return;
|
||||
setHiddenGeometryIds((prev) => {
|
||||
const seen = new Set(prev);
|
||||
for (const id of nextIds) {
|
||||
seen.add(id);
|
||||
}
|
||||
return Array.from(seen);
|
||||
});
|
||||
},
|
||||
showOnlyGeometries: (ids) => {
|
||||
const keepIds = new Set(normalizeIdList(ids));
|
||||
if (!keepIds.size) return;
|
||||
setHiddenGeometryIds(
|
||||
draft.features
|
||||
.map((feature) => String(feature.properties.id))
|
||||
.filter((id) => !keepIds.has(id))
|
||||
);
|
||||
},
|
||||
showAllGeometries: () => {
|
||||
setHiddenGeometryIds([]);
|
||||
},
|
||||
setDialog: setDialogWithRef,
|
||||
getDialog: () => dialogRef.current,
|
||||
};
|
||||
|
||||
const playFromIndex = useCallback(async (startIndex: number) => {
|
||||
console.log("playFromIndex starting at:", startIndex, "flatSteps count:", flatSteps.length);
|
||||
if (!flatSteps.length) return;
|
||||
const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex));
|
||||
resetPresentation();
|
||||
@@ -271,7 +277,10 @@ export function useReplayPreview({
|
||||
setIsPlaying(true);
|
||||
|
||||
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];
|
||||
setActiveCursor({
|
||||
@@ -282,7 +291,10 @@ export function useReplayPreview({
|
||||
onSelectStep(current.stageId, current.stepIndex);
|
||||
|
||||
const controllers = controllersRef.current;
|
||||
if (!controllers) return;
|
||||
if (!controllers) {
|
||||
console.warn("playFromIndex aborted: controllersRef.current is null!");
|
||||
return;
|
||||
}
|
||||
controllers.map = getMapInstance();
|
||||
controllers.draft = draft;
|
||||
|
||||
@@ -322,9 +334,10 @@ export function useReplayPreview({
|
||||
}, [playFromIndex]);
|
||||
|
||||
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);
|
||||
}, [flatSteps, playFromIndex, selectedStageId, selectedStepIndex]);
|
||||
}, [flatSteps, playFromIndex]);
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
|
||||
Reference in New Issue
Block a user