Files
History-user/src/uhm/components/preview/PreviewLayout.tsx
T

775 lines
30 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react";
import type { RefObject, Dispatch, SetStateAction } from "react";
import { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch";
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import RelatedEntityPopup from "./RelatedEntityPopup";
import PinnedWikiPopup from "./PinnedWikiPopup";
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
import { fetchWikiById, type Wiki } from "@/uhm/api/wikis";
import type { Entity } from "@/uhm/api/entities";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { type BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { PreviewRelationIndex } from "@/uhm/lib/preview/types";
import type { Feature } from "@/uhm/lib/editor/state/useEditorState";
type Props = {
projectId: string;
mode: "preview" | "replay_preview";
onModeChange: (mode: "preview" | "replay_preview") => void;
onExitPreview: () => void;
draft: FeatureCollection;
replays: BattleReplay[];
entities: Entity[];
wikis: WikiSnapshot[];
entityWikiLinks?: EntityWikiLinkSnapshot[];
backgroundVisibility: BackgroundLayerVisibility;
onBackgroundVisibilityChange: (vis: BackgroundLayerVisibility) => void;
geometryVisibility: Record<string, boolean>;
onGeometryVisibilityChange: (vis: Record<string, boolean>) => void;
viewMode?: "local" | "global";
onViewModeChange?: (mode: "local" | "global") => void;
globalGeometries?: FeatureCollection;
isGlobalLoading?: boolean;
baseline?: FeatureCollection;
activeReplay?: BattleReplay | null;
selectedStageId?: number | null;
selectedStepIndex?: number | null;
autoplayMode?: "start" | "selection" | null;
replayPreview: any;
mapHandleRef?: RefObject<MapHandle | null>;
previewRelations: PreviewRelationIndex;
previewActiveEntityId: string | null;
setPreviewActiveEntityId: (id: string | null) => void;
previewEntityFocusToken?: number;
setPreviewEntityFocusToken: Dispatch<SetStateAction<number>>;
previewSidebarWidth: number;
setPreviewSidebarWidth: Dispatch<SetStateAction<number>>;
previewWikiCache: Record<string, Wiki>;
setPreviewWikiCache: Dispatch<SetStateAction<Record<string, Wiki>>>;
isLargeScreen?: boolean;
setIsLargeScreen?: Dispatch<SetStateAction<boolean>>;
};
export type PreviewLayoutHandle = {
handleFeatureClick: (payload: MapFeaturePayload | null) => void;
getHoverPopupContent: (feature: Feature) => MapHoverPopupContent | null;
handlePlaySelectedReplay: (replay: BattleReplay) => void;
};
const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
projectId,
mode,
onModeChange,
onExitPreview,
wikis,
backgroundVisibility,
onBackgroundVisibilityChange,
geometryVisibility,
onGeometryVisibilityChange,
activeReplay,
autoplayMode = null,
replayPreview,
mapHandleRef,
previewRelations,
previewActiveEntityId,
setPreviewActiveEntityId,
setPreviewEntityFocusToken,
previewSidebarWidth,
setPreviewSidebarWidth,
previewWikiCache,
setPreviewWikiCache,
isLargeScreen,
}: Props, ref) => {
const isReplayPreviewMode = mode === "replay_preview";
// State for local active replay (when played from standard preview click)
const [localActiveReplay, setLocalActiveReplay] = useState<BattleReplay | null>(null);
const currentActiveReplay = activeReplay !== undefined ? activeReplay : localActiveReplay;
// Preview specific UI states
const [previewWikiError, setPreviewWikiError] = useState<string | null>(null);
const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false);
const [previewPinnedWikiPopupAnchor, setPreviewPinnedWikiPopupAnchor] = useState<MapFeaturePayload | null>(null);
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<{
slug: string;
entities: Entity[];
top: number;
left: number;
} | null>(null);
// Focused present place (for PresentPlaceSearch)
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
// Clear preview states when currentActiveReplay or mode changes
useEffect(() => {
setPreviewWikiCache({});
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
setPreviewPinnedWikiPopupAnchor(null);
setPreviewActiveEntityId(null);
setIsPreviewEntitySidebarOpen(false);
setPreviewLinkEntityPopup(null);
}, [currentActiveReplay, mode, setPreviewActiveEntityId, setPreviewWikiCache]);
const autoplayedReplayIdRef = useRef<string | number | null>(null);
// Autoplay replay on mount/session load
useEffect(() => {
if (!isReplayPreviewMode || !currentActiveReplay || !autoplayMode) {
autoplayedReplayIdRef.current = null;
return;
}
if (autoplayedReplayIdRef.current === currentActiveReplay.id) return;
autoplayedReplayIdRef.current = currentActiveReplay.id;
if (autoplayMode === "selection") {
replayPreview.playFromSelection();
} else {
replayPreview.playFromStart();
}
}, [autoplayMode, isReplayPreviewMode, currentActiveReplay, replayPreview]);
const {
timelineYear: replayPreviewTimelineYear,
resetPreview: resetReplayPreview,
playbackSpeed: replayPreviewPlaybackSpeed,
activeCursor: replayPreviewActiveCursor,
activeWikiId: replayPreviewActiveWikiId,
sidebarOpen: replayPreviewSidebarOpen,
openWikiPanelById: openReplayPreviewWikiPanelById,
closeWikiPanel: closeReplayPreviewWikiPanel,
} = replayPreview;
// Timeline bar parameters
const activeTimelineYear = isReplayPreviewMode ? replayPreviewTimelineYear : replayPreviewTimelineYear;
// Timeline bar visibility
const timelineBarVisible = !isReplayPreviewMode || replayPreview.timelineVisible;
// Replay step active label
const replayPreviewActiveStepLabel = useMemo(() => {
if (
replayPreviewActiveCursor.stageId == null ||
replayPreviewActiveCursor.stepIndex == null
) {
return null;
}
return `Stage #${replayPreviewActiveCursor.stageId} · Step ${replayPreviewActiveCursor.stepIndex + 1}`;
}, [replayPreviewActiveCursor.stageId, replayPreviewActiveCursor.stepIndex]);
// Active wiki snapshot
const replayPreviewActiveWikiSnapshot = useMemo(() => {
if (!replayPreviewActiveWikiId) return null;
return wikis.find((item) => item.id === replayPreviewActiveWikiId) || null;
}, [replayPreviewActiveWikiId, wikis]);
// Load active wiki content if needed
useEffect(() => {
if (!mode || !replayPreviewSidebarOpen) {
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
return;
}
const activeWikiId = String(replayPreviewActiveWikiId || "").trim();
if (!activeWikiId.length) {
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
return;
}
const localWiki = wikis.find((item) => item.id === activeWikiId) || null;
if (!localWiki) {
setPreviewWikiError("Không tìm thấy wiki trong snapshot preview.");
setIsPreviewWikiLoading(false);
return;
}
if (typeof localWiki.doc === "string") {
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
return;
}
if (previewWikiCache[activeWikiId]) {
setPreviewWikiError(null);
setIsPreviewWikiLoading(false);
return;
}
let disposed = false;
setPreviewWikiError(null);
setIsPreviewWikiLoading(true);
void fetchWikiById(activeWikiId)
.then((row) => {
if (disposed) return;
setPreviewWikiCache((prev) => ({ ...prev, [activeWikiId]: row }));
})
.catch((err) => {
if (disposed) return;
setPreviewWikiError(err instanceof Error ? err.message : "Không tải được wiki preview.");
})
.finally(() => {
if (!disposed) {
setIsPreviewWikiLoading(false);
}
});
return () => {
disposed = true;
};
}, [
mode,
previewWikiCache,
replayPreviewActiveWikiId,
replayPreviewSidebarOpen,
wikis,
]);
// Active wiki fully built
const replayPreviewActiveWiki = useMemo<Wiki | null>(() => {
const snapshotWiki = replayPreviewActiveWikiSnapshot;
if (!snapshotWiki) return null;
if (typeof snapshotWiki.doc === "string") {
return {
id: snapshotWiki.id,
project_id: projectId,
title: snapshotWiki.title,
slug: snapshotWiki.slug ?? null,
content: snapshotWiki.doc || "",
};
}
return previewWikiCache[snapshotWiki.id] || null;
}, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]);
// Active entity
const replayPreviewActiveEntityId = useMemo(() => {
const activeWikiEntityIds = replayPreviewActiveWikiId
? previewRelations.wikiEntityIdsById[String(replayPreviewActiveWikiId)] || []
: [];
if (
previewActiveEntityId &&
(!activeWikiEntityIds.length || activeWikiEntityIds.includes(previewActiveEntityId))
) {
return previewActiveEntityId;
}
return activeWikiEntityIds[0] || previewActiveEntityId;
}, [previewActiveEntityId, previewRelations.wikiEntityIdsById, replayPreviewActiveWikiId]);
const replayPreviewActiveEntity = replayPreviewActiveEntityId
? previewRelations.entitiesById[replayPreviewActiveEntityId] || null
: null;
const isReplayPreviewWikiSidebarOpen = mode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen);
// Handle replay preview entity selection
const selectReplayPreviewEntity = useCallback((
entityId: string,
options?: {
sourceFeatureId?: string | number | null;
preferredWikiId?: string | null;
preferredWikiSlug?: string | null;
focusMap?: boolean;
selectGeometry?: boolean;
}
) => {
const id = String(entityId || "").trim();
const entity = previewRelations.entitiesById[id] || null;
if (!entity) return;
const linkedWikis = previewRelations.entityWikisById[id] || [];
const preferredWikiId = String(options?.preferredWikiId || "").trim();
const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim();
const nextWiki =
linkedWikis.find((wiki) => preferredWikiId && wiki.id === preferredWikiId) ||
linkedWikis.find((wiki) => preferredWikiSlug && String(wiki.slug || "").trim() === preferredWikiSlug) ||
linkedWikis[0] ||
null;
setPreviewActiveEntityId(id);
setIsPreviewEntitySidebarOpen(true);
setPreviewWikiError(null);
setPreviewPinnedWikiPopupAnchor(null);
setPreviewLinkEntityPopup(null);
if (options?.focusMap === true) {
setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1);
}
if (nextWiki) {
openReplayPreviewWikiPanelById(nextWiki.id);
}
}, [
openReplayPreviewWikiPanelById,
previewRelations.entitiesById,
previewRelations.entityWikisById,
setPreviewActiveEntityId,
setPreviewEntityFocusToken,
]);
// Handle close sidebar
const closeReplayPreviewSidebar = useCallback(() => {
closeReplayPreviewWikiPanel();
setPreviewActiveEntityId(null);
setIsPreviewEntitySidebarOpen(false);
setPreviewWikiError(null);
setPreviewLinkEntityPopup(null);
}, [closeReplayPreviewWikiPanel, setPreviewActiveEntityId]);
// Play selected battle replay
const handlePlaySelectedReplay = useCallback((replay: BattleReplay) => {
setLocalActiveReplay(replay);
onModeChange("replay_preview");
}, [onModeChange]);
// Exit Replay Preview mode
const handleExitReplayPreview = useCallback(() => {
resetReplayPreview();
if (activeReplay !== undefined) {
// Started directly from parent
onExitPreview();
} else {
// Started locally
setLocalActiveReplay(null);
onModeChange("preview");
}
}, [activeReplay, onExitPreview, onModeChange, resetReplayPreview]);
// Map feature click handler
const handlePreviewMapFeatureClick = useCallback((payload: MapFeaturePayload | null) => {
setPreviewLinkEntityPopup(null);
if (!payload) {
setPreviewPinnedWikiPopupAnchor(null);
return;
}
const entityIds = previewRelations.geometryEntityIds[String(payload.featureId)] || [];
const rows = entityIds.flatMap((entityId) => {
const entity = previewRelations.entitiesById[entityId] || null;
if (!entity) return [];
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
if (!linkedWikis.length) {
return [{ entity, wiki: null as Wiki | null }];
}
return linkedWikis.map((wiki) => ({ entity, wiki }));
});
if (!rows.length) {
setPreviewPinnedWikiPopupAnchor(null);
return;
}
if (rows.length === 1) {
const row = rows[0];
selectReplayPreviewEntity(row.entity.id, {
sourceFeatureId: payload.featureId,
preferredWikiId: row.wiki?.id,
focusMap: false,
selectGeometry: false,
});
setPreviewPinnedWikiPopupAnchor(null);
return;
}
setPreviewPinnedWikiPopupAnchor(payload);
}, [
previewRelations.entitiesById,
previewRelations.entityWikisById,
previewRelations.geometryEntityIds,
selectReplayPreviewEntity,
]);
// Hover popup content provider
const getPreviewHoverPopupContent = useCallback((feature: Feature) => {
const entityIds = normalizeFeatureEntityIds(feature);
const entitiesForFeature = entityIds
.map((entityId) => previewRelations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (!entitiesForFeature.length) return null;
return {
rows: entitiesForFeature.flatMap((entity) => {
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
if (!linkedWikis.length) {
return [{ title: entity.name || String(entity.id), quote: "" }];
}
return linkedWikis.map((wiki) => ({
title: entity.name || String(entity.id),
quote: extractWikiBlockquoteText(wiki.content),
}));
}),
};
}, [previewRelations.entitiesById, previewRelations.entityWikisById]);
// Wiki inner links click handler
const handleReplayPreviewWikiLinkRequest = useCallback(({ slug, rect }: { slug: string; rect: DOMRect }) => {
const nextSlug = String(slug || "").trim();
if (!nextSlug.length) return;
const localWiki = wikis.find((item) => String(item.slug || "").trim() === nextSlug) || null;
if (!localWiki) {
setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`);
return;
}
const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || [];
const linkedEntities = linkedEntityIds
.map((entityId) => previewRelations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (linkedEntities.length === 1) {
selectReplayPreviewEntity(linkedEntities[0].id, {
preferredWikiId: localWiki.id,
focusMap: false,
});
return;
}
if (!linkedEntities.length) return;
const popupWidth = 240;
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
setPreviewLinkEntityPopup({
slug: nextSlug,
entities: linkedEntities,
top,
left,
});
}, [
previewRelations.entitiesById,
previewRelations.wikiEntityIdsBySlug,
selectReplayPreviewEntity,
wikis,
]);
// Search and focus place
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),
});
}
}, [mapHandleRef]);
const clearPresentPlaceFocus = useCallback(() => {
setFocusedPresentPlace(null);
}, []);
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
setFocusedPresentPlace(null);
setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1);
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 });
}
const linkedEntityIds = previewRelations.geometryEntityIds[String(payload.geometry.id)] || [];
if (linkedEntityIds.length === 1) {
selectReplayPreviewEntity(linkedEntityIds[0], {
sourceFeatureId: payload.geometry.id,
focusMap: false,
selectGeometry: false,
});
}
}, [mapHandleRef, previewRelations.geometryEntityIds, selectReplayPreviewEntity, setPreviewEntityFocusToken]);
const effectiveGeometryVisibility = useMemo(() => {
return geometryVisibility;
}, [geometryVisibility]);
const computedTimelineStyle = useMemo(() => {
const rightMargin = (isReplayPreviewWikiSidebarOpen && isLargeScreen)
? previewSidebarWidth + 32
: 18;
return {
left: "88px",
right: `${rightMargin}px`,
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
};
}, [isReplayPreviewWikiSidebarOpen, isLargeScreen, previewSidebarWidth]);
// Popup PinnedWikiPopup rows
const previewPinnedWikiPopupRows = useMemo(() => {
if (!previewPinnedWikiPopupAnchor) return [];
const entityIds = previewRelations.geometryEntityIds[String(previewPinnedWikiPopupAnchor.featureId)] || [];
return entityIds.flatMap((entityId) => {
const entity = previewRelations.entitiesById[entityId] || null;
if (!entity) return [];
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
if (!linkedWikis.length) {
return [{ entity, wiki: null as Wiki | null, quote: "" }];
}
return linkedWikis.map((wiki) => ({
entity,
wiki,
quote: extractWikiBlockquoteText(wiki.content),
}));
});
}, [previewPinnedWikiPopupAnchor, previewRelations]);
useImperativeHandle(ref, () => ({
handleFeatureClick: handlePreviewMapFeatureClick,
getHoverPopupContent: getPreviewHoverPopupContent,
handlePlaySelectedReplay,
}), [handlePreviewMapFeatureClick, getPreviewHoverPopupContent, handlePlaySelectedReplay]);
return (
<>
<PresentPlaceSearch
focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onClearFocus={clearPresentPlaceFocus}
leftOffset={18}
/>
{isReplayPreviewMode ? (
<ReplayPreviewOverlay
isPreviewMode={true}
isPlaying={replayPreview.isPlaying}
dialog={replayPreview.dialog}
toasts={replayPreview.toasts}
sidebarOpen={isReplayPreviewWikiSidebarOpen}
sidebarWidth={previewSidebarWidth}
playbackSpeed={replayPreviewPlaybackSpeed}
activeStepLabel={replayPreviewActiveStepLabel}
activeStepNumber={replayPreview.activeStepNumber}
totalSteps={replayPreview.totalSteps}
onPlayPreview={replayPreview.playFromStart}
onStopPreview={replayPreview.stopPreview}
onResetPreview={replayPreview.resetPreview}
onExitPreview={handleExitReplayPreview}
/>
) : null}
{isReplayPreviewWikiSidebarOpen ? (
<aside
style={{
position: "absolute",
top: 16,
right: 16,
bottom: 16,
maxWidth: "calc(100vw - 2rem)",
zIndex: 20,
}}
>
<PublicWikiSidebar
entity={replayPreviewActiveEntity}
wiki={replayPreviewActiveWiki}
isLoading={isPreviewWikiLoading}
error={
replayPreview.activeWikiId || replayPreviewActiveEntity
? previewWikiError
: "Chưa có wiki được chọn trong step này."
}
onClose={closeReplayPreviewSidebar}
onWikiLinkRequest={handleReplayPreviewWikiLinkRequest}
sidebarWidth={previewSidebarWidth}
onSidebarWidthChange={setPreviewSidebarWidth}
maxDragWidth={typeof window !== "undefined" ? Math.min(800, window.innerWidth - 340) : 800}
compactHeader
/>
</aside>
) : null}
<aside
style={{
position: "absolute",
top: 80,
bottom: 20,
left: 18,
zIndex: 16,
display: "flex",
flexDirection: "column",
width: 58,
pointerEvents: "none",
}}
>
<div
style={{
flexGrow: 1,
flexShrink: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={effectiveGeometryVisibility}
onToggleBackground={(id) =>
onBackgroundVisibilityChange({
...backgroundVisibility,
[id]: !backgroundVisibility[id],
})
}
onToggleGeometry={(typeKey) =>
onGeometryVisibilityChange({
...geometryVisibility,
[typeKey]: geometryVisibility[typeKey] === false,
})
}
/>
</div>
</aside>
{previewPinnedWikiPopupAnchor && previewPinnedWikiPopupRows.length > 0 ? (
<PinnedWikiPopup
rows={previewPinnedWikiPopupRows}
featureId={previewPinnedWikiPopupAnchor.featureId}
top={clampNumber(
previewPinnedWikiPopupAnchor.point.y - 8,
16,
typeof window !== "undefined" ? window.innerHeight - 280 : previewPinnedWikiPopupAnchor.point.y - 8
)}
left={clampNumber(
previewPinnedWikiPopupAnchor.point.x + 18,
16,
typeof window !== "undefined" ? window.innerWidth - 340 : previewPinnedWikiPopupAnchor.point.x + 18
)}
onClose={() => setPreviewPinnedWikiPopupAnchor(null)}
onSelectRow={(entityId, wikiId) => {
selectReplayPreviewEntity(entityId, {
sourceFeatureId: previewPinnedWikiPopupAnchor.featureId,
preferredWikiId: wikiId,
focusMap: false,
selectGeometry: false,
});
}}
/>
) : null}
{timelineBarVisible ? (
<TimelineBar
year={activeTimelineYear}
onYearChange={(year) => {
// Standard timeline bar year change
replayPreview.setTimelineYear(year);
}}
timeRange={0}
onTimeRangeChange={() => {}}
isLoading={false}
disabled={isReplayPreviewMode}
statusText={null}
filterEnabled={replayPreview.timelineFilterEnabled}
onFilterEnabledChange={replayPreview.setTimelineFilterEnabled}
style={computedTimelineStyle}
/>
) : null}
{previewLinkEntityPopup ? (
<RelatedEntityPopup
slug={previewLinkEntityPopup.slug}
entities={previewLinkEntityPopup.entities}
top={previewLinkEntityPopup.top}
left={previewLinkEntityPopup.left}
onClose={() => setPreviewLinkEntityPopup(null)}
onSelectEntity={(entityId) => {
selectReplayPreviewEntity(entityId, {
preferredWikiSlug: previewLinkEntityPopup.slug,
focusMap: false,
});
setPreviewLinkEntityPopup(null);
}}
/>
) : null}
</>
);
});
export default PreviewLayout;
// ==========================================
// Helper functions
// ==========================================
function extractWikiBlockquoteText(content: string | null | undefined): string {
if (!content) return "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
const rawText = blockquoteMatch?.[1]?.trim() || "";
if (!rawText) return "";
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
const margin = 12;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
const preferredLeft = rect.right + margin;
const maxLeft = Math.max(margin, viewportWidth - width - margin);
const left = Math.min(preferredLeft, maxLeft);
const preferredTop = rect.top;
const maxTop = Math.max(margin, viewportHeight - height - margin);
const top = Math.max(margin, Math.min(preferredTop, maxTop));
return { top, left };
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
if (value < min) return min;
if (value > max) return max;
return value;
}