feat: implement PinnedWikiPopup, RelatedEntityPopup, and PreviewLayout to support entity relationship navigation and wiki content display in editor preview mode.
This commit is contained in:
+185
-778
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user