refactor UI recusion for wiki
This commit is contained in:
@@ -2,6 +2,7 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { Wiki } from "@/uhm/api/wikis";
|
||||
import type { Geometry } from "@/uhm/types/geo";
|
||||
|
||||
const RELATION_BATCH_SIZE = 10;
|
||||
const RELATION_BATCH_CONCURRENCY = 4;
|
||||
@@ -12,8 +13,42 @@ export type WikiContentPreview = {
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type RelationGeometry = {
|
||||
id: string;
|
||||
draw_geometry: Geometry;
|
||||
type?: string | null;
|
||||
geo_type?: number | null;
|
||||
bound_with?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
};
|
||||
|
||||
type RelationType =
|
||||
| "wiki-entity"
|
||||
| "entity-wiki"
|
||||
| "geometry-entity"
|
||||
| "entity-geometry"
|
||||
| "entity-geometry-child"
|
||||
| "entity-geometry-alone";
|
||||
|
||||
const entitiesPromiseCache: Record<string, Promise<Entity[]>> = {};
|
||||
|
||||
export async function fetchRelationMap<T>(type: RelationType, ids: string[]): Promise<Record<string, T[]>> {
|
||||
const uniqueIds = uniqueStrings(ids);
|
||||
if (!uniqueIds.length) return {};
|
||||
return requestJson<Record<string, T[]>>(
|
||||
`${API_ENDPOINTS.relations}?type=${encodeURIComponent(type)}&${buildArrayQuery("ids", uniqueIds)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchEntitiesByWikiIds(ids: string[]): Promise<Record<string, Entity[]>> {
|
||||
return fetchRelationMap<Entity>("wiki-entity", ids);
|
||||
}
|
||||
|
||||
export async function fetchGeometriesByEntityIds(ids: string[]): Promise<Record<string, RelationGeometry[]>> {
|
||||
return fetchRelationMap<RelationGeometry>("entity-geometry", ids);
|
||||
}
|
||||
|
||||
export async function fetchEntitiesByGeometryIds(ids: string[]): Promise<Record<string, Entity[]>> {
|
||||
const uniqueIds = uniqueStrings(ids);
|
||||
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
|
||||
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
activeStepLabel: string | null;
|
||||
activeStepNumber: number | null;
|
||||
totalSteps: number;
|
||||
playButtonLabel?: string;
|
||||
onPlayPreview: () => void;
|
||||
onStopPreview: () => void;
|
||||
onResetPreview: () => void;
|
||||
@@ -32,6 +33,7 @@ export default function ReplayPreviewOverlay({
|
||||
activeStepLabel,
|
||||
activeStepNumber,
|
||||
totalSteps,
|
||||
playButtonLabel = "Phát lại",
|
||||
onPlayPreview,
|
||||
onStopPreview,
|
||||
onResetPreview,
|
||||
@@ -279,7 +281,7 @@ export default function ReplayPreviewOverlay({
|
||||
onClick={onPlayPreview}
|
||||
style={previewButtonStyle("#166534")}
|
||||
>
|
||||
Phát lại
|
||||
{playButtonLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
|
||||
export type GeometrySelectionGeometry = {
|
||||
id: string;
|
||||
center: [number, number] | null;
|
||||
adminLabel: string | null;
|
||||
adminAddress: string | null;
|
||||
};
|
||||
|
||||
export type GeometrySelectionRow = {
|
||||
entity: Entity;
|
||||
geometries: GeometrySelectionGeometry[];
|
||||
featureCollection: FeatureCollection;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
wikiSlug: string;
|
||||
rows: GeometrySelectionRow[];
|
||||
isLoading: boolean;
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
onSelectEntity: (entityId: string) => void;
|
||||
};
|
||||
|
||||
export default function GeometrySelectionPanel({
|
||||
wikiSlug,
|
||||
rows,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onSelectEntity,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
borderRadius: 20,
|
||||
border: "1px solid rgba(148, 163, 184, 0.22)",
|
||||
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))",
|
||||
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
|
||||
padding: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "1.2px",
|
||||
fontWeight: 900,
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
Geometry
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.3,
|
||||
color: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
Chọn entity để zoom
|
||||
</div>
|
||||
{wikiSlug ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
lineHeight: "17px",
|
||||
color: "#94a3b8",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
/wiki/{wikiSlug}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: 28,
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
border: "1px solid rgba(148, 163, 184, 0.25)",
|
||||
background: "rgba(30, 41, 59, 0.4)",
|
||||
color: "#94a3b8",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
transition: "all 0.2s",
|
||||
outline: "none",
|
||||
}}
|
||||
className="hover:bg-slate-700/50 hover:text-slate-100"
|
||||
aria-label="Close geometry chooser"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="uhm-pinned-geometry-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
height: index === 0 ? 64 : 82,
|
||||
borderRadius: 10,
|
||||
background: "rgba(148, 163, 184, 0.12)",
|
||||
}}
|
||||
className="animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ fontSize: 14, lineHeight: "20px", color: "#f87171" }}>
|
||||
{error}
|
||||
</div>
|
||||
) : rows.length ? (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{rows.map(({ entity, geometries }, index) => {
|
||||
const adminText = getGeometryAdminText(geometries);
|
||||
return (
|
||||
<div
|
||||
key={entity.id}
|
||||
style={{
|
||||
paddingTop: index > 0 ? 12 : 0,
|
||||
marginTop: index > 0 ? 4 : 0,
|
||||
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEntity(entity.id)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "9px 10px 9px 12px",
|
||||
border: "1px solid transparent",
|
||||
borderRadius: 10,
|
||||
background: "rgba(15, 23, 42, 0.34)",
|
||||
boxShadow: "inset 2px 0 0 rgba(56, 189, 248, 0.52)",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease, border-color 0.15s ease",
|
||||
}}
|
||||
className="hover:border-sky-400/30 hover:bg-sky-500/10"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
lineHeight: "20px",
|
||||
color: "#f8fafc",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{entity.name || String(entity.id)} {formatEntityYears(entity)}
|
||||
</div>
|
||||
{adminText ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
lineHeight: "17px",
|
||||
color: "#94a3b8",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{adminText}
|
||||
</div>
|
||||
) : entity.description?.trim() ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
lineHeight: "17px",
|
||||
color: "#94a3b8",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{entity.description.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 14, lineHeight: "20px", color: "#94a3b8" }}>
|
||||
Wiki này chưa có entity hoặc geometry liên quan.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.uhm-pinned-geometry-panel-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
}
|
||||
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-thumb {
|
||||
border: 2px solid rgba(15, 23, 42, 0.95);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEntityYears(entity: Entity): string {
|
||||
const start = Number.isFinite(entity.time_start) ? String(entity.time_start) : "";
|
||||
const end = Number.isFinite(entity.time_end) ? String(entity.time_end) : "";
|
||||
if (!start && !end) return "";
|
||||
return `(${start || "?"}-${end || "?"})`;
|
||||
}
|
||||
|
||||
function getGeometryAdminText(geometries: GeometrySelectionGeometry[]): string {
|
||||
const labels = geometries
|
||||
.map((geometry) => geometry.adminLabel || geometry.adminAddress || "")
|
||||
.map((label) => label.trim())
|
||||
.filter((label) => label.length > 0);
|
||||
return Array.from(new Set(labels)).join(" / ");
|
||||
}
|
||||
@@ -527,34 +527,28 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
}
|
||||
|
||||
const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || [];
|
||||
const linkedEntities = linkedEntityIds
|
||||
.map((entityId) => previewRelations.entitiesById[entityId] || null)
|
||||
.filter((entity): entity is Entity => Boolean(entity));
|
||||
const firstEntityId = linkedEntityIds[0] || null;
|
||||
if (!firstEntityId) return;
|
||||
|
||||
if (linkedEntities.length === 1) {
|
||||
selectReplayPreviewEntity(linkedEntities[0].id, {
|
||||
preferredWikiId: localWiki.id,
|
||||
focusMap: false,
|
||||
});
|
||||
return;
|
||||
setPreviewActiveEntityId(firstEntityId);
|
||||
setIsPreviewEntitySidebarOpen(true);
|
||||
setPreviewRightPanelMode("wiki");
|
||||
setPreviewWikiSelectionPanelAnchor(null);
|
||||
setPreviewWikiError(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
openReplayPreviewWikiPanelById(localWiki.id);
|
||||
|
||||
const geometries = previewRelations.entityGeometriesById[firstEntityId];
|
||||
const map = mapHandleRef?.current?.getMap();
|
||||
if (map && geometries && geometries.features.length > 0) {
|
||||
fitMapToFeatureCollection(map, geometries, 96, { duration: 1000, maxZoom: 8, pointZoom: 6 });
|
||||
}
|
||||
|
||||
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,
|
||||
mapHandleRef,
|
||||
openReplayPreviewWikiPanelById,
|
||||
previewRelations.entityGeometriesById,
|
||||
previewRelations.wikiEntityIdsBySlug,
|
||||
selectReplayPreviewEntity,
|
||||
setPreviewActiveEntityId,
|
||||
wikis,
|
||||
]);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ type Props = {
|
||||
wikiError?: string | null;
|
||||
onCloseWikiSidebar?: () => void;
|
||||
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
||||
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
||||
sidebarWidth?: number;
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
maxSidebarDragWidth?: number;
|
||||
@@ -59,6 +60,7 @@ type Props = {
|
||||
onLayerPanelVisibleChange?: (visible: boolean) => void;
|
||||
sidebarHeight?: number;
|
||||
onSidebarHeightChange?: (height: number) => void;
|
||||
showViewportControls?: boolean;
|
||||
};
|
||||
|
||||
export default function PreviewMapShell({
|
||||
@@ -91,6 +93,7 @@ export default function PreviewMapShell({
|
||||
wikiError = null,
|
||||
onCloseWikiSidebar,
|
||||
onWikiLinkRequest,
|
||||
onWikiLinkEntitySelectionRequest,
|
||||
sidebarWidth,
|
||||
onSidebarWidthChange,
|
||||
maxSidebarDragWidth,
|
||||
@@ -105,6 +108,7 @@ export default function PreviewMapShell({
|
||||
onLayerPanelVisibleChange,
|
||||
sidebarHeight,
|
||||
onSidebarHeightChange,
|
||||
showViewportControls = true,
|
||||
}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
@@ -133,6 +137,8 @@ export default function PreviewMapShell({
|
||||
fetchUserAvatar();
|
||||
}, []);
|
||||
|
||||
const hasWikiSidebar = Boolean(activeEntity || activeWiki || isWikiLoading || wikiError);
|
||||
|
||||
const menuOptionStyle: CSSProperties = {
|
||||
width: 46,
|
||||
height: 46,
|
||||
@@ -172,7 +178,7 @@ export default function PreviewMapShell({
|
||||
onHoverFeatureChange={onHoverFeatureChange}
|
||||
onPlayPreviewReplay={onPlayPreviewReplay}
|
||||
onLoad={onLoad}
|
||||
showViewportControls={!isMobileOrTablet}
|
||||
showViewportControls={showViewportControls && !isMobileOrTablet}
|
||||
height="100svh"
|
||||
/>
|
||||
|
||||
@@ -207,7 +213,7 @@ export default function PreviewMapShell({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
bottom: ((activeEntity || activeWiki) && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
||||
bottom: (hasWikiSidebar && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
||||
left: 18,
|
||||
zIndex: 18,
|
||||
display: "flex",
|
||||
@@ -412,7 +418,7 @@ export default function PreviewMapShell({
|
||||
|
||||
{overlay}
|
||||
|
||||
{activeEntity || activeWiki ? (
|
||||
{hasWikiSidebar ? (
|
||||
<aside
|
||||
className={isMobileOrTablet ? "uhm-public-wiki-sidebar" : "uhm-public-wiki-sidebar absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
|
||||
style={isMobileOrTablet ? {
|
||||
@@ -438,6 +444,7 @@ export default function PreviewMapShell({
|
||||
error={wikiError}
|
||||
onClose={onCloseWikiSidebar || (() => {})}
|
||||
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
|
||||
onWikiLinkEntitySelectionRequest={onWikiLinkEntitySelectionRequest}
|
||||
sidebarWidth={sidebarWidth}
|
||||
onSidebarWidthChange={onSidebarWidthChange}
|
||||
maxDragWidth={maxSidebarDragWidth}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,9 +39,10 @@ export function usePublicPreviewInteraction(options: {
|
||||
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
|
||||
timelineYear?: number | null;
|
||||
replayActiveWikiId?: string | null;
|
||||
replayMode?: "idle" | "playing";
|
||||
replayMode?: "idle" | "playing" | "paused";
|
||||
onWikiLinkNavigate?: (wiki: Wiki) => void | Promise<void>;
|
||||
}) {
|
||||
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode } = options;
|
||||
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode, onWikiLinkNavigate } = options;
|
||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
|
||||
@@ -50,15 +51,20 @@ export function usePublicPreviewInteraction(options: {
|
||||
setIsManualSidebarOpen(false);
|
||||
}, [replayMode]);
|
||||
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
|
||||
const [visibleWiki, setVisibleWiki] = useState<CachedWiki | null>(null);
|
||||
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
|
||||
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||
const hoverWikiPreviewRequestsRef = useRef<Set<string>>(new Set());
|
||||
const wikiLinkRequestSeqRef = useRef(0);
|
||||
const wikiLinkInFlightSlugRef = useRef<string | null>(null);
|
||||
const fullWikiFetchAttemptedSlugRef = useRef<Set<string>>(new Set());
|
||||
const suppressSelectedFeatureAutoSelectRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (replayMode === "playing" && replayActiveWikiId) {
|
||||
if (replayMode !== "idle" && replayActiveWikiId) {
|
||||
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
|
||||
const entityId = activeWikiEntityIds[0] || null;
|
||||
const wikiSlug = relations.wikiById[String(replayActiveWikiId)]?.slug || null;
|
||||
@@ -83,9 +89,15 @@ export function usePublicPreviewInteraction(options: {
|
||||
|
||||
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
|
||||
const activeWiki = useMemo(() => {
|
||||
if (visibleWiki) return visibleWiki;
|
||||
if (!activeWikiSlug) return null;
|
||||
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||
const cachedWiki = findWikiBySlug(wikiCache, activeWikiSlug) || null;
|
||||
const relationWiki = findWikiBySlug(relations.wikiBySlug, activeWikiSlug) || null;
|
||||
if (!cachedWiki) return relationWiki;
|
||||
if (hasWikiContent(cachedWiki) || cachedWiki.id === "__not_found__") return cachedWiki;
|
||||
if (relationWiki && hasWikiContent(relationWiki)) return relationWiki;
|
||||
return cachedWiki;
|
||||
}, [activeWikiSlug, relations.wikiBySlug, visibleWiki, wikiCache]);
|
||||
|
||||
const selectEntity = useCallback(async (
|
||||
entityId: string,
|
||||
@@ -172,15 +184,17 @@ export function usePublicPreviewInteraction(options: {
|
||||
: "") ||
|
||||
firstWikiSlug(linkedWikis);
|
||||
|
||||
const cachedFullWiki = nextWikiSlug ? findWikiWithContentBySlug(wikiCache, nextWikiSlug) || null : null;
|
||||
setActiveEntityId(entityId);
|
||||
setActiveWikiSlug(nextWikiSlug);
|
||||
setVisibleWiki(cachedFullWiki);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setIsManualSidebarOpen(true);
|
||||
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
|
||||
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
|
||||
}
|
||||
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds]);
|
||||
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds, wikiCache]);
|
||||
|
||||
const selectWiki = useCallback(async (
|
||||
wiki: Wiki
|
||||
@@ -195,23 +209,30 @@ export function usePublicPreviewInteraction(options: {
|
||||
|
||||
if (wiki.slug) {
|
||||
const slug = wiki.slug;
|
||||
const cachedFullWiki = findWikiWithContentBySlug(wikiCache, slug) || null;
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[slug]: {
|
||||
[slug]: cachedFullWiki || {
|
||||
...wiki,
|
||||
__fetched: false,
|
||||
},
|
||||
}));
|
||||
setActiveWikiSlug(slug);
|
||||
setVisibleWiki(cachedFullWiki);
|
||||
}
|
||||
setActiveEntityId(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setIsManualSidebarOpen(true);
|
||||
}, [relations.wikiEntityIdsById, selectEntity]);
|
||||
}, [relations.wikiEntityIdsById, selectEntity, wikiCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds.length) return;
|
||||
if (!selectedFeatureIds.length) {
|
||||
suppressSelectedFeatureAutoSelectRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (suppressSelectedFeatureAutoSelectRef.current) return;
|
||||
|
||||
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
|
||||
if (linkedEntityIds.length !== 1) return;
|
||||
|
||||
@@ -422,17 +443,99 @@ export function usePublicPreviewInteraction(options: {
|
||||
};
|
||||
}, [activeEntityId, activeWikiSlug, relations.entityWikisById, setRelations]);
|
||||
|
||||
const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined;
|
||||
const cachedWiki = activeWikiSlug ? findWikiBySlug(wikiCache, activeWikiSlug) : undefined;
|
||||
const fetchFullWikiBySlug = useCallback(async (slug: string): Promise<Wiki | null> => {
|
||||
const row = await fetchWikiBySlug(slug);
|
||||
if (!row) return null;
|
||||
|
||||
let versionContent = row.content;
|
||||
try {
|
||||
if (row.content_sample?.[0]?.id) {
|
||||
const res = await getContentByVersionWikiId(row.content_sample[0].id);
|
||||
const content = extractWikiContentFromResponse(res);
|
||||
if (content) versionContent = content;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch version content:", err);
|
||||
}
|
||||
|
||||
return { ...row, content: versionContent };
|
||||
}, []);
|
||||
|
||||
const focusWikiLinkAfterPaint = useCallback((wiki: Wiki) => {
|
||||
if (!onWikiLinkNavigate) return;
|
||||
|
||||
const run = () => {
|
||||
void Promise.resolve(onWikiLinkNavigate(wiki)).catch((err) => {
|
||||
console.error("Failed to focus map for wiki link:", err);
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(run);
|
||||
});
|
||||
}, [onWikiLinkNavigate]);
|
||||
|
||||
const publishWikiToPanel = useCallback((wiki: Wiki, requestedSlug?: string | null): CachedWiki => {
|
||||
const canonicalSlug = String(wiki.slug || "").trim() || String(requestedSlug || "").trim();
|
||||
const fullWiki: CachedWiki = {
|
||||
...wiki,
|
||||
slug: canonicalSlug,
|
||||
__fetched: true,
|
||||
};
|
||||
if (requestedSlug) fullWikiFetchAttemptedSlugRef.current.add(requestedSlug);
|
||||
if (canonicalSlug) fullWikiFetchAttemptedSlugRef.current.add(canonicalSlug);
|
||||
|
||||
setWikiCache((prev) => cacheWikiBySlug(prev, fullWiki, requestedSlug));
|
||||
setActiveWikiSlug(canonicalSlug);
|
||||
setVisibleWiki(fullWiki);
|
||||
setActiveWikiError(hasWikiContent(fullWiki) ? null : "Wiki này chưa có nội dung.");
|
||||
setIsActiveWikiLoading(false);
|
||||
return fullWiki;
|
||||
}, []);
|
||||
|
||||
const prepareManualWikiNavigation = useCallback(() => {
|
||||
suppressSelectedFeatureAutoSelectRef.current = true;
|
||||
setSelectedFeatureIds([]);
|
||||
setActiveEntityId(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setIsManualSidebarOpen(true);
|
||||
}, [setSelectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWikiSlug) {
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError(null);
|
||||
setVisibleWiki(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wikiLinkInFlightSlugRef.current === activeWikiSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedWiki?.id === "__not_found__") {
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedWiki && cachedWiki.__fetched && hasWikiContent(cachedWiki)) {
|
||||
setVisibleWiki(cachedWiki);
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
|
||||
if (cachedWiki?.__fetched && fullWikiFetchAttemptedSlugRef.current.has(activeWikiSlug)) {
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError(cachedWiki.id === "__not_found__" ? "Không tìm thấy wiki cho entity đã chọn." : null);
|
||||
setActiveWikiError(hasWikiContent(cachedWiki) ? null : "Wiki này chưa có nội dung.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,26 +544,13 @@ export function usePublicPreviewInteraction(options: {
|
||||
setIsActiveWikiLoading(true);
|
||||
setActiveWikiError(null);
|
||||
try {
|
||||
const row = await fetchWikiBySlug(activeWikiSlug);
|
||||
const row = await fetchFullWikiBySlug(activeWikiSlug);
|
||||
if (disposed) return;
|
||||
|
||||
if (row) {
|
||||
let versionContent = row.content;
|
||||
try {
|
||||
if (row.content_sample?.[0]?.id) {
|
||||
const res = await getContentByVersionWikiId(row.content_sample[0].id);
|
||||
if (res?.data?.content) versionContent = res.data.content;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch version content:", err);
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true },
|
||||
}));
|
||||
publishWikiToPanel(row, activeWikiSlug);
|
||||
} else {
|
||||
fullWikiFetchAttemptedSlugRef.current.add(activeWikiSlug);
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
|
||||
@@ -478,45 +568,66 @@ export function usePublicPreviewInteraction(options: {
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [activeWikiSlug, cachedWiki]);
|
||||
}, [activeWikiSlug, cachedWiki, fetchFullWikiBySlug, publishWikiToPanel]);
|
||||
|
||||
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
||||
const linkedEntities = linkedEntityIds
|
||||
.map((entityId) => relations.entitiesById[entityId] || null)
|
||||
.filter((entity): entity is Entity => Boolean(entity));
|
||||
const nextSlug = String(slug || "").trim();
|
||||
if (!nextSlug) return;
|
||||
|
||||
if (linkedEntities.length === 1) {
|
||||
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
|
||||
const requestSeq = ++wikiLinkRequestSeqRef.current;
|
||||
wikiLinkInFlightSlugRef.current = nextSlug;
|
||||
prepareManualWikiNavigation();
|
||||
|
||||
const cachedWikiForSlug =
|
||||
findWikiWithContentBySlug(wikiCache, nextSlug) ||
|
||||
findWikiWithContentBySlug(relations.wikiBySlug, nextSlug) ||
|
||||
null;
|
||||
|
||||
if (cachedWikiForSlug && cachedWikiForSlug.id !== "__not_found__" && hasWikiContent(cachedWikiForSlug)) {
|
||||
wikiLinkInFlightSlugRef.current = null;
|
||||
const fullWiki = publishWikiToPanel(cachedWikiForSlug, nextSlug);
|
||||
focusWikiLinkAfterPaint(fullWiki);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
|
||||
try {
|
||||
const row = await fetchWikiBySlug(slug);
|
||||
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
|
||||
} catch (err) {
|
||||
console.error("Load wiki by slug failed", err);
|
||||
}
|
||||
setIsActiveWikiLoading(true);
|
||||
|
||||
let row: Wiki | null = null;
|
||||
try {
|
||||
row = await fetchFullWikiBySlug(nextSlug);
|
||||
} catch (err) {
|
||||
console.error("Load wiki by slug failed", err);
|
||||
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
|
||||
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
|
||||
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
|
||||
setIsActiveWikiLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!linkedEntities.length) return;
|
||||
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
|
||||
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
|
||||
|
||||
const popupWidth = 240;
|
||||
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
|
||||
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
|
||||
if (!row) {
|
||||
setActiveWikiError("Không tìm thấy wiki.");
|
||||
setIsActiveWikiLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkEntityPopup({
|
||||
slug,
|
||||
entities: linkedEntities,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
|
||||
const fullWiki = publishWikiToPanel(row, nextSlug);
|
||||
focusWikiLinkAfterPaint(fullWiki);
|
||||
}, [
|
||||
fetchFullWikiBySlug,
|
||||
focusWikiLinkAfterPaint,
|
||||
prepareManualWikiNavigation,
|
||||
publishWikiToPanel,
|
||||
relations.wikiBySlug,
|
||||
wikiCache,
|
||||
]);
|
||||
|
||||
const closeWikiSidebar = useCallback(() => {
|
||||
setActiveEntityId(null);
|
||||
setActiveWikiSlug(null);
|
||||
setVisibleWiki(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setSelectedFeatureIds([]);
|
||||
@@ -526,6 +637,7 @@ export function usePublicPreviewInteraction(options: {
|
||||
const closeWikiSidebarPreserveSelection = useCallback(() => {
|
||||
setActiveEntityId(null);
|
||||
setActiveWikiSlug(null);
|
||||
setVisibleWiki(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setIsManualSidebarOpen(false);
|
||||
@@ -555,6 +667,113 @@ async function fetchRelationWikisForEntity(entityId: string): Promise<Wiki[]> {
|
||||
return rows[entityId] || [];
|
||||
}
|
||||
|
||||
function extractWikiContentFromResponse(response: unknown): string {
|
||||
if (typeof response === "string") return response;
|
||||
if (!response || typeof response !== "object") return "";
|
||||
const source = response as Record<string, unknown>;
|
||||
if (typeof source.content === "string") return source.content;
|
||||
const data = source.data;
|
||||
if (data && typeof data === "object" && typeof (data as Record<string, unknown>).content === "string") {
|
||||
return (data as Record<string, unknown>).content as string;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasWikiContent(wiki: Wiki | null | undefined): boolean {
|
||||
return typeof wiki?.content === "string" && wiki.content.trim().length > 0;
|
||||
}
|
||||
|
||||
function cacheWikiBySlug(
|
||||
prev: Record<string, CachedWiki>,
|
||||
wiki: CachedWiki,
|
||||
requestedSlug?: string | null
|
||||
): Record<string, CachedWiki> {
|
||||
const next = { ...prev };
|
||||
for (const key of wikiSlugCacheKeys(requestedSlug, wiki.slug)) {
|
||||
next[key] = wiki;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function findWikiWithContentBySlug<T extends Wiki>(
|
||||
source: Record<string, T>,
|
||||
slug: string | null | undefined
|
||||
): T | undefined {
|
||||
const direct = findWikiBySlug(source, slug);
|
||||
if (direct && hasWikiContent(direct)) return direct;
|
||||
|
||||
const targetKey = normalizeWikiSlugForCompare(slug);
|
||||
if (!targetKey) return undefined;
|
||||
for (const [key, wiki] of Object.entries(source)) {
|
||||
if (!hasWikiContent(wiki)) continue;
|
||||
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
|
||||
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
|
||||
if (keyMatches || slugMatches) return wiki;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findWikiBySlug<T extends Wiki>(
|
||||
source: Record<string, T>,
|
||||
slug: string | null | undefined
|
||||
): T | undefined {
|
||||
const keys = wikiSlugCacheKeys(slug);
|
||||
for (const key of keys) {
|
||||
const direct = source[key];
|
||||
if (direct) return direct;
|
||||
}
|
||||
|
||||
const targetKey = normalizeWikiSlugForCompare(slug);
|
||||
if (!targetKey) return undefined;
|
||||
for (const [key, wiki] of Object.entries(source)) {
|
||||
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
|
||||
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
|
||||
if (keyMatches || slugMatches) return wiki;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function wikiSlugCacheKeys(...values: Array<string | null | undefined>): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const value of values) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) continue;
|
||||
keys.add(raw);
|
||||
|
||||
let decoded = raw;
|
||||
try {
|
||||
decoded = decodeURIComponent(raw);
|
||||
} catch {
|
||||
decoded = raw;
|
||||
}
|
||||
decoded = decoded.replace(/^\/+/, "").replace(/^wiki\//i, "").trim();
|
||||
if (!decoded) continue;
|
||||
|
||||
keys.add(decoded);
|
||||
keys.add(decoded.replace(/_/g, " "));
|
||||
keys.add(decoded.replace(/\s+/g, "_"));
|
||||
keys.add(normalizeWikiSlugForCompare(decoded));
|
||||
}
|
||||
return Array.from(keys).filter((key) => key.length > 0);
|
||||
}
|
||||
|
||||
function normalizeWikiSlugForCompare(value: string | null | undefined): string {
|
||||
let raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
raw = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Keep the original value if it is not valid percent-encoded text.
|
||||
}
|
||||
return raw
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^wiki\//i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("vi-VN");
|
||||
}
|
||||
|
||||
function cleanPreviewQuoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, memo } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, memo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
// Loaded dynamically inside the component to prevent render-blocking
|
||||
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
||||
sidebarWidth?: number;
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
maxDragWidth?: number;
|
||||
@@ -73,6 +75,42 @@ function isExternalHref(href: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function extractWikiSlugFromHref(href: string): string {
|
||||
const raw = String(href || "").trim();
|
||||
if (!raw.length || raw === "__missing__") return "";
|
||||
if (raw.startsWith("#wiki:")) return raw.slice("#wiki:".length).trim();
|
||||
if (raw.startsWith("#")) return "";
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z\d+.-]*:/i.test(raw);
|
||||
const baseOrigin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
||||
if (isAbsoluteUrl) {
|
||||
try {
|
||||
const url = new URL(raw, baseOrigin);
|
||||
if (typeof window !== "undefined" && url.origin !== window.location.origin) return "";
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path.startsWith("/wiki/")) return "";
|
||||
return decodeWikiSlug(path.slice("/wiki/".length));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const match = raw.match(/^([^?#]+)([?#].*)?$/);
|
||||
let slug = String(match?.[1] || "").replace(/^\/+/, "").replace(/\/+$/, "").trim();
|
||||
if (slug.startsWith("wiki/")) {
|
||||
slug = slug.slice("wiki/".length).trim();
|
||||
}
|
||||
return decodeWikiSlug(slug);
|
||||
}
|
||||
|
||||
function decodeWikiSlug(slug: string): string {
|
||||
try {
|
||||
return decodeURIComponent(slug).trim();
|
||||
} catch {
|
||||
return slug.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(inputHtml, "text/html");
|
||||
@@ -83,21 +121,18 @@ function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||
const href = String(a.getAttribute("href") || "").trim();
|
||||
if (!href.length) continue;
|
||||
if (href === "__missing__") continue;
|
||||
if (href.startsWith("#")) continue;
|
||||
if (href.startsWith("/")) continue;
|
||||
const slugPart = extractWikiSlugFromHref(href);
|
||||
if (slugPart.length) {
|
||||
a.setAttribute("href", `#wiki:${slugPart}`);
|
||||
a.setAttribute("data-wiki-slug", slugPart);
|
||||
a.setAttribute("target", "_self");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = href.match(/^([^?#]+)([?#].*)?$/);
|
||||
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
|
||||
if (!slugPart.length) continue;
|
||||
a.setAttribute("href", `#wiki:${slugPart}`);
|
||||
a.setAttribute("data-wiki-slug", slugPart);
|
||||
a.setAttribute("target", "_self");
|
||||
}
|
||||
|
||||
const toc: TocItem[] = [];
|
||||
@@ -133,6 +168,7 @@ function PublicWikiSidebar({
|
||||
error,
|
||||
onClose,
|
||||
onWikiLinkRequest,
|
||||
onWikiLinkEntitySelectionRequest,
|
||||
sidebarWidth,
|
||||
onSidebarWidthChange,
|
||||
maxDragWidth,
|
||||
@@ -142,6 +178,12 @@ function PublicWikiSidebar({
|
||||
}: Props) {
|
||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const tocContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [wikiLinkMenu, setWikiLinkMenu] = useState<{
|
||||
slug: string;
|
||||
rect: DOMRect;
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import("react-quill-new/dist/quill.snow.css");
|
||||
@@ -225,6 +267,15 @@ function PublicWikiSidebar({
|
||||
? activeHeadingId
|
||||
: (toc[0]?.id ?? null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const firstHeadingId = toc[0]?.id ?? null;
|
||||
setActiveHeadingId(firstHeadingId);
|
||||
|
||||
const scrollContainer = contentRootRef.current?.parentElement;
|
||||
scrollContainer?.scrollTo({ top: 0, behavior: "auto" });
|
||||
tocContainerRef.current?.scrollTo({ left: 0, behavior: "auto" });
|
||||
}, [wiki?.id, wiki?.slug, renderHtml, toc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toc.length) return;
|
||||
const root = contentRootRef.current;
|
||||
@@ -236,19 +287,33 @@ function PublicWikiSidebar({
|
||||
if (!headings.length) return;
|
||||
|
||||
const scrollContainer = root.parentElement;
|
||||
const updateActiveHeading = () => {
|
||||
const containerRect = scrollContainer?.getBoundingClientRect();
|
||||
const topBoundary = (containerRect?.top ?? 0) + (containerRect?.height ?? window.innerHeight) * 0.18;
|
||||
const bottomBoundary = (containerRect?.top ?? 0) + (containerRect?.height ?? window.innerHeight) * 0.82;
|
||||
const visibleHeadings = headings
|
||||
.map((heading) => ({ heading, rect: heading.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => rect.bottom >= topBoundary && rect.top <= bottomBoundary)
|
||||
.sort((a, b) => {
|
||||
const aDistance = Math.abs(a.rect.top - topBoundary);
|
||||
const bDistance = Math.abs(b.rect.top - topBoundary);
|
||||
return aDistance - bDistance;
|
||||
});
|
||||
const nextHeading = visibleHeadings[0]?.heading || headings[0];
|
||||
if (nextHeading?.id) setActiveHeadingId(nextHeading.id);
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
||||
const top = visible[0]?.target as HTMLElement | undefined;
|
||||
if (top?.id) setActiveHeadingId(top.id);
|
||||
},
|
||||
updateActiveHeading,
|
||||
{ root: scrollContainer || null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
||||
);
|
||||
|
||||
for (const heading of headings) observer.observe(heading);
|
||||
return () => observer.disconnect();
|
||||
scrollContainer?.addEventListener("scroll", updateActiveHeading, { passive: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
scrollContainer?.removeEventListener("scroll", updateActiveHeading);
|
||||
};
|
||||
}, [toc]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -275,18 +340,85 @@ function PublicWikiSidebar({
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
const sourceLink = link || fallbackLink;
|
||||
if (!sourceLink) return;
|
||||
|
||||
const slug = String(link.getAttribute("data-wiki-slug") || "").trim();
|
||||
const slug = String(
|
||||
sourceLink.getAttribute("data-wiki-slug") ||
|
||||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
|
||||
).trim();
|
||||
if (!slug.length) return;
|
||||
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
|
||||
|
||||
event.preventDefault();
|
||||
onWikiLinkRequest({ slug, rect: sourceLink.getBoundingClientRect() });
|
||||
};
|
||||
|
||||
root.addEventListener("click", handleClick);
|
||||
return () => root.removeEventListener("click", handleClick);
|
||||
}, [onWikiLinkRequest, renderHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
||||
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
const sourceLink = link || fallbackLink;
|
||||
if (!sourceLink) return;
|
||||
|
||||
const slug = String(
|
||||
sourceLink.getAttribute("data-wiki-slug") ||
|
||||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
|
||||
).trim();
|
||||
if (!slug.length) return;
|
||||
|
||||
event.preventDefault();
|
||||
setWikiLinkMenu({
|
||||
slug,
|
||||
rect: sourceLink.getBoundingClientRect(),
|
||||
...computeContextMenuPosition(event.clientX, event.clientY, 220, 88),
|
||||
});
|
||||
};
|
||||
|
||||
root.addEventListener("contextmenu", handleContextMenu, true);
|
||||
return () => root.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
}, [renderHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wikiLinkMenu) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest?.("[data-wiki-link-context-menu='true']")) return;
|
||||
setWikiLinkMenu(null);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setWikiLinkMenu(null);
|
||||
};
|
||||
const closeMenu = () => setWikiLinkMenu(null);
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", closeMenu);
|
||||
window.addEventListener("scroll", closeMenu, true);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", closeMenu);
|
||||
window.removeEventListener("scroll", closeMenu, true);
|
||||
};
|
||||
}, [wikiLinkMenu]);
|
||||
|
||||
const handleOpenStandaloneWiki = (slug: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = `/wiki/${encodeURIComponent(slug)}`;
|
||||
const nextWindow = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (nextWindow) nextWindow.opener = null;
|
||||
};
|
||||
|
||||
const isExpanded = useMemo(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const fullHeight = Math.round(window.innerHeight * 0.70);
|
||||
@@ -546,7 +678,7 @@ function PublicWikiSidebar({
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading && !wiki ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
|
||||
<div
|
||||
style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
|
||||
@@ -566,12 +698,38 @@ function PublicWikiSidebar({
|
||||
{error}
|
||||
</div>
|
||||
) : wiki ? (
|
||||
<div
|
||||
ref={contentRootRef}
|
||||
className="uhm-wiki-sidebar-view ql-editor"
|
||||
style={{ fontSize: 14, color: "#cbd5e1" }}
|
||||
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
||||
/>
|
||||
<div style={{ position: "relative", minHeight: "100%" }}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
zIndex: 2,
|
||||
overflow: "hidden",
|
||||
background: "rgba(56, 189, 248, 0.08)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="uhm-wiki-sidebar-loading-bar"
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "42%",
|
||||
background: "linear-gradient(90deg, transparent, #38bdf8, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={contentRootRef}
|
||||
className="uhm-wiki-sidebar-view ql-editor"
|
||||
style={{ fontSize: 14, color: "#cbd5e1" }}
|
||||
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
|
||||
Entity này chưa có wiki liên kết.
|
||||
@@ -579,6 +737,57 @@ function PublicWikiSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wikiLinkMenu && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
data-wiki-link-context-menu="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: wikiLinkMenu.top,
|
||||
left: wikiLinkMenu.left,
|
||||
zIndex: 100000,
|
||||
width: 220,
|
||||
overflow: "hidden",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(148, 163, 184, 0.28)",
|
||||
background: "rgba(15, 23, 42, 0.98)",
|
||||
boxShadow: "0 18px 42px rgba(2, 6, 23, 0.48)",
|
||||
color: "#e2e8f0",
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleOpenStandaloneWiki(wikiLinkMenu.slug);
|
||||
setWikiLinkMenu(null);
|
||||
}}
|
||||
style={wikiLinkMenuButtonStyle}
|
||||
className="hover:bg-sky-500/15 hover:text-sky-100"
|
||||
>
|
||||
Mở wiki riêng
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const request = { slug: wikiLinkMenu.slug, rect: wikiLinkMenu.rect };
|
||||
if (onWikiLinkEntitySelectionRequest) {
|
||||
onWikiLinkEntitySelectionRequest(request);
|
||||
} else {
|
||||
onWikiLinkRequest(request);
|
||||
}
|
||||
setWikiLinkMenu(null);
|
||||
}}
|
||||
style={wikiLinkMenuButtonStyle}
|
||||
className="hover:bg-sky-500/15 hover:text-sky-100"
|
||||
>
|
||||
Mở bảng chọn entity
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
|
||||
<style jsx global>{`
|
||||
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -606,6 +815,17 @@ function PublicWikiSidebar({
|
||||
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
@keyframes uhm-wiki-sidebar-loading-bar {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
.uhm-wiki-sidebar-loading-bar {
|
||||
animation: uhm-wiki-sidebar-loading-bar 1.1s ease-in-out infinite;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor {
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
@@ -726,4 +946,29 @@ function PublicWikiSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const wikiLinkMenuButtonStyle = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
border: 0,
|
||||
borderRadius: 7,
|
||||
background: "transparent",
|
||||
padding: "9px 10px",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
lineHeight: "18px",
|
||||
textAlign: "left" as const,
|
||||
};
|
||||
|
||||
function computeContextMenuPosition(clientX: number, clientY: number, width: number, height: number) {
|
||||
const margin = 8;
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
|
||||
return {
|
||||
left: Math.max(margin, Math.min(clientX, viewportWidth - width - margin)),
|
||||
top: Math.max(margin, Math.min(clientY, viewportHeight - height - margin)),
|
||||
};
|
||||
}
|
||||
|
||||
export default memo(PublicWikiSidebar);
|
||||
|
||||
@@ -214,8 +214,10 @@ export function useReplayPreview({
|
||||
|
||||
const stopPreview = useCallback(() => {
|
||||
runIdRef.current += 1;
|
||||
restorePreviewState();
|
||||
}, [restorePreviewState]);
|
||||
isPlayingRef.current = false;
|
||||
setIsPlaying(false);
|
||||
getMapInstance()?.stop();
|
||||
}, [getMapInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
runIdRef.current += 1;
|
||||
|
||||
Reference in New Issue
Block a user