feat: implement PinnedWikiPopup, RelatedEntityPopup, and PreviewLayout to support entity relationship navigation and wiki content display in editor preview mode.
This commit is contained in:
+188
-781
File diff suppressed because it is too large
Load Diff
@@ -62,20 +62,40 @@ export function useMapInstance() {
|
|||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
const syncZoomLevel = () => {
|
let throttleTimeout: any = null;
|
||||||
|
|
||||||
|
const syncZoomLevelImmediate = () => {
|
||||||
if (isZoomSliderDraggingRef.current) return;
|
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", () => {
|
map.on("load", () => {
|
||||||
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
syncZoomLevel();
|
syncZoomLevelImmediate();
|
||||||
map.on("zoom", syncZoomLevel);
|
map.on("zoom", syncZoomLevelThrottled);
|
||||||
|
map.on("zoomend", syncZoomLevelImmediate);
|
||||||
setIsMapLoaded(true);
|
setIsMapLoaded(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off("zoom", syncZoomLevel);
|
if (throttleTimeout) {
|
||||||
|
clearTimeout(throttleTimeout);
|
||||||
|
}
|
||||||
|
map.off("zoom", syncZoomLevelThrottled);
|
||||||
|
map.off("zoomend", syncZoomLevelImmediate);
|
||||||
setIsMapLoaded(false);
|
setIsMapLoaded(false);
|
||||||
if (mapRef.current === map) {
|
if (mapRef.current === map) {
|
||||||
mapRef.current = null;
|
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>) => {
|
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const startX = event.clientX;
|
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 onMove = (e: PointerEvent) => {
|
||||||
const deltaX = e.clientX - lastX;
|
ghost.style.left = `${e.clientX}px`;
|
||||||
if (deltaX !== 0) {
|
|
||||||
onDrag(deltaX);
|
|
||||||
lastX = e.clientX;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
|
||||||
|
const onUp = (e: PointerEvent) => {
|
||||||
window.removeEventListener("pointermove", onMove);
|
window.removeEventListener("pointermove", onMove);
|
||||||
window.removeEventListener("pointerup", onUp);
|
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);
|
window.addEventListener("pointermove", onMove);
|
||||||
|
|||||||
@@ -161,7 +161,30 @@ function PublicWikiSidebar({
|
|||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
const startWidth = width;
|
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) => {
|
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 deltaX = e.clientX - startX;
|
||||||
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
|
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
|
||||||
setWidth(nextWidth);
|
setWidth(nextWidth);
|
||||||
@@ -170,11 +193,6 @@ function PublicWikiSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUp = () => {
|
|
||||||
window.removeEventListener("pointermove", onMove);
|
|
||||||
window.removeEventListener("pointerup", onUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", onMove);
|
window.addEventListener("pointermove", onMove);
|
||||||
window.addEventListener("pointerup", onUp);
|
window.addEventListener("pointerup", onUp);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user