feat: implement PinnedWikiPopup, RelatedEntityPopup, and PreviewLayout to support entity relationship navigation and wiki content display in editor preview mode.

This commit is contained in:
taDuc
2026-05-27 12:15:21 +07:00
parent 3d21d078cf
commit ef3766bc2a
7 changed files with 1446 additions and 798 deletions
+25 -5
View File
@@ -62,20 +62,40 @@ export function useMapInstance() {
mapRef.current = map;
const syncZoomLevel = () => {
let throttleTimeout: any = null;
const syncZoomLevelImmediate = () => {
if (isZoomSliderDraggingRef.current) return;
setZoomLevel(roundZoom(map.getZoom()));
const currentMap = mapRef.current;
if (!currentMap) return;
const next = roundZoom(currentMap.getZoom());
setZoomLevel((prev) => (prev === next ? prev : next));
};
const syncZoomLevelThrottled = () => {
if (isZoomSliderDraggingRef.current) return;
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
throttleTimeout = null;
syncZoomLevelImmediate();
}, 150);
};
map.on("load", () => {
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
syncZoomLevel();
map.on("zoom", syncZoomLevel);
syncZoomLevelImmediate();
map.on("zoom", syncZoomLevelThrottled);
map.on("zoomend", syncZoomLevelImmediate);
setIsMapLoaded(true);
});
return () => {
map.off("zoom", syncZoomLevel);
if (throttleTimeout) {
clearTimeout(throttleTimeout);
}
map.off("zoom", syncZoomLevelThrottled);
map.off("zoomend", syncZoomLevelImmediate);
setIsMapLoaded(false);
if (mapRef.current === map) {
mapRef.current = null;
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
type PopupRow = {
entity: Entity;
wiki: Wiki | null;
quote: string;
};
type Props = {
rows: PopupRow[];
featureId: string | number;
top: number;
left: number;
onClose: () => void;
onSelectRow: (entityId: string, wikiId?: string) => void;
};
export default function PinnedWikiPopup({
rows,
featureId,
top,
left,
onClose,
onSelectRow,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && containerRef.current?.contains(target)) {
return;
}
onClose();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [onClose]);
return (
<div
ref={containerRef}
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
style={{ left, top }}
>
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
<div className="max-h-[300px] overflow-y-auto p-3">
<div className="grid gap-2">
{rows.map(({ entity, wiki, quote }) => (
<button
key={`${entity.id}:${wiki?.id || "entity-only"}`}
type="button"
onClick={() => {
onSelectRow(entity.id, wiki?.id);
}}
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-3 text-left transition hover:border-sky-400/40 hover:bg-sky-500/10"
>
<div className="truncate text-sm font-semibold text-white">
{entity.name || String(entity.id)}
</div>
{quote ? (
<div
className="mt-2 pl-3 pr-1 text-sm italic leading-relaxed text-slate-300"
style={{
borderLeft: "3px solid rgba(56, 189, 248, 0.4)",
display: "-webkit-box",
WebkitLineClamp: 4,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "normal",
}}
>
{quote}
</div>
) : null}
</button>
))}
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
type Props = {
slug: string;
entities: Entity[];
top: number;
left: number;
onClose: () => void;
onSelectEntity: (entityId: string) => void;
};
export default function RelatedEntityPopup({
slug,
entities,
top,
left,
onClose,
onSelectEntity,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && containerRef.current?.contains(target)) {
return;
}
onClose();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [onClose]);
return (
<div
ref={containerRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top, left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
<div className="grid gap-1">
{entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
onSelectEntity(entity.id);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
</div>
</div>
);
}
+24 -7
View File
@@ -12,18 +12,35 @@ export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
let lastX = startX;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
const deltaX = e.clientX - lastX;
if (deltaX !== 0) {
onDrag(deltaX);
lastX = e.clientX;
}
ghost.style.left = `${e.clientX}px`;
};
const onUp = () => {
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaX = e.clientX - startX;
if (deltaX !== 0) {
onDrag(deltaX);
}
};
window.addEventListener("pointermove", onMove);
+23 -5
View File
@@ -161,7 +161,30 @@ function PublicWikiSidebar({
const startX = event.clientX;
const startWidth = width;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.left = `${e.clientX}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaX = e.clientX - startX;
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
setWidth(nextWidth);
@@ -170,11 +193,6 @@ function PublicWikiSidebar({
}
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};