use requestAnimationFrame for hover popup
This commit is contained in:
+184
-229
@@ -409,8 +409,7 @@ function EditorPageContent() {
|
||||
const [previewWikiError, setPreviewWikiError] = useState<string | null>(null);
|
||||
// State loading riêng cho wiki preview sidebar.
|
||||
const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false);
|
||||
const [previewFeaturePopupAnchor, setPreviewFeaturePopupAnchor] = useState<MapFeaturePayload | null>(null);
|
||||
const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState<string | null>(null);
|
||||
const [previewPinnedWikiPopupAnchor, setPreviewPinnedWikiPopupAnchor] = useState<MapFeaturePayload | null>(null);
|
||||
const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null);
|
||||
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
|
||||
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
|
||||
@@ -422,7 +421,7 @@ function EditorPageContent() {
|
||||
});
|
||||
const [isGlobalLoading, setIsGlobalLoading] = useState(false);
|
||||
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<PreviewLinkEntityPopupState | null>(null);
|
||||
const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0);
|
||||
const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState<number | null>(null);
|
||||
const [previewSidebarWidth, setPreviewSidebarWidth] = useState<number>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("public-wiki-sidebar-width");
|
||||
@@ -442,6 +441,7 @@ function EditorPageContent() {
|
||||
// Ref giữ object URL hiện tại để revoke khi đổi/xóa ảnh, tránh leak bộ nhớ.
|
||||
const imageOverlayObjectUrlRef = useRef<string | null>(null);
|
||||
const previewLinkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewPinnedWikiPopupRef = useRef<HTMLDivElement | null>(null);
|
||||
// Cập nhật stage/step được chọn trong sidebar replay.
|
||||
const handleReplaySelectionChange = useCallback((stageId: number | null, stepIndex: number | null) => {
|
||||
setReplaySelection({ stageId, stepIndex });
|
||||
@@ -483,7 +483,7 @@ function EditorPageContent() {
|
||||
essential: true,
|
||||
});
|
||||
setFocusedPresentPlace(place);
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
}, [getCurrentMapInstance]);
|
||||
const previewReturnModeRef = useRef<EditorMode>("select");
|
||||
@@ -1026,7 +1026,8 @@ function EditorPageContent() {
|
||||
const clearPreviewViewerState = useCallback(() => {
|
||||
setPreviewActiveEntityId(null);
|
||||
setIsPreviewEntitySidebarOpen(false);
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
setPreviewEntityFocusToken(null);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
setPreviewWikiError(null);
|
||||
closeReplayPreviewWikiPanel();
|
||||
@@ -1335,20 +1336,6 @@ function EditorPageContent() {
|
||||
replayPreviewWikiRows,
|
||||
]
|
||||
);
|
||||
const previewFeaturePopupEntityIds = useMemo(() => {
|
||||
if (!previewFeaturePopupAnchor) return [];
|
||||
return previewRelations.geometryEntityIds[String(previewFeaturePopupAnchor.featureId)] || [];
|
||||
}, [previewFeaturePopupAnchor, previewRelations.geometryEntityIds]);
|
||||
const previewFeaturePopupEntities = useMemo(
|
||||
() => previewFeaturePopupEntityIds
|
||||
.map((entityId) => previewRelations.entitiesById[entityId] || null)
|
||||
.filter((entity): entity is Entity => Boolean(entity)),
|
||||
[previewFeaturePopupEntityIds, previewRelations.entitiesById]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewExpandedEntityId(null);
|
||||
}, [previewFeaturePopupAnchor]);
|
||||
// Wiki snapshot đang được step preview yêu cầu mở.
|
||||
const replayPreviewActiveWikiSnapshot = useMemo(() => {
|
||||
if (!replayPreviewActiveWikiId) return null;
|
||||
@@ -1516,7 +1503,7 @@ function EditorPageContent() {
|
||||
const renderedFeature = mapRenderDraft.features.find((item) => String(item.properties.id) === geometryId) || null;
|
||||
setSelectedFeatureIds(renderedFeature ? [renderedFeature.properties.id] : []);
|
||||
setFocusedPresentPlace(null);
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
}, [
|
||||
activeTimelineFilterEnabled,
|
||||
@@ -1566,10 +1553,11 @@ function EditorPageContent() {
|
||||
setPreviewActiveEntityId(id);
|
||||
setIsPreviewEntitySidebarOpen(true);
|
||||
setPreviewWikiError(null);
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
|
||||
if (options?.focusMap !== false) {
|
||||
setPreviewEntityFocusToken((prev) => prev + 1);
|
||||
if (options?.focusMap === true) {
|
||||
setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1);
|
||||
}
|
||||
if (options?.selectGeometry && options.sourceFeatureId != null) {
|
||||
setSelectedFeatureIds([options.sourceFeatureId]);
|
||||
@@ -1584,11 +1572,103 @@ function EditorPageContent() {
|
||||
setSelectedFeatureIds,
|
||||
]);
|
||||
|
||||
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.entitiesById,
|
||||
previewRelations.entityWikisById,
|
||||
previewRelations.geometryEntityIds,
|
||||
]);
|
||||
|
||||
const handlePreviewMapFeatureClick = useCallback((payload: MapFeaturePayload | null) => {
|
||||
if (!isAnyPreviewMode) return;
|
||||
setPreviewFeaturePopupAnchor(payload);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
}, [isAnyPreviewMode]);
|
||||
|
||||
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);
|
||||
}, [
|
||||
isAnyPreviewMode,
|
||||
previewRelations.entitiesById,
|
||||
previewRelations.entityWikisById,
|
||||
previewRelations.geometryEntityIds,
|
||||
selectReplayPreviewEntity,
|
||||
]);
|
||||
|
||||
const getPreviewHoverPopupContent = useCallback((feature: Feature) => {
|
||||
if (!isAnyPreviewMode) return null;
|
||||
|
||||
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),
|
||||
}));
|
||||
}),
|
||||
};
|
||||
}, [isAnyPreviewMode, previewRelations.entitiesById, previewRelations.entityWikisById]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewLinkEntityPopup) return;
|
||||
@@ -1610,6 +1690,26 @@ function EditorPageContent() {
|
||||
};
|
||||
}, [previewLinkEntityPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewPinnedWikiPopupAnchor) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setPreviewPinnedWikiPopupAnchor(null);
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && previewPinnedWikiPopupRef.current?.contains(target)) return;
|
||||
setPreviewPinnedWikiPopupAnchor(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
};
|
||||
}, [previewPinnedWikiPopupAnchor]);
|
||||
|
||||
// Điều hướng link wiki nội bộ trong preview nhưng chỉ trong phạm vi snapshot preview.
|
||||
const handleReplayPreviewWikiLinkRequest = useCallback(({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||
const nextSlug = String(slug || "").trim();
|
||||
@@ -1629,6 +1729,7 @@ function EditorPageContent() {
|
||||
selectReplayPreviewEntity(linkedEntities[0].id, {
|
||||
preferredWikiId: match.id,
|
||||
preferredWikiSlug: nextSlug,
|
||||
focusMap: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3031,6 +3132,7 @@ function EditorPageContent() {
|
||||
onHideFeature={handleHideGeometryLocal}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
allowGeometryEditing={!isAnyPreviewMode}
|
||||
allowFeatureSelection={!isAnyPreviewMode}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
geometryVisibility={effectiveGeometryVisibility}
|
||||
applyGeometryBindingFilter={
|
||||
@@ -3041,6 +3143,8 @@ function EditorPageContent() {
|
||||
: geometryBindingFilterEnabled
|
||||
}
|
||||
onFeatureClick={isAnyPreviewMode ? handlePreviewMapFeatureClick : undefined}
|
||||
hoverPopupEnabled={isAnyPreviewMode}
|
||||
getHoverPopupContent={getPreviewHoverPopupContent}
|
||||
|
||||
focusFeatureCollection={
|
||||
isAnyPreviewMode
|
||||
@@ -3084,6 +3188,7 @@ function EditorPageContent() {
|
||||
dialog={replayPreview.dialog}
|
||||
toasts={replayPreview.toasts}
|
||||
sidebarOpen={isReplayPreviewWikiSidebarOpen}
|
||||
sidebarWidth={previewSidebarWidth}
|
||||
playbackSpeed={replayPreview.playbackSpeed}
|
||||
activeStepLabel={replayPreviewActiveStepLabel}
|
||||
activeStepNumber={replayPreview.activeStepNumber}
|
||||
@@ -3115,6 +3220,7 @@ function EditorPageContent() {
|
||||
sidebarWidth={previewSidebarWidth}
|
||||
onSidebarWidthChange={setPreviewSidebarWidth}
|
||||
maxDragWidth={typeof window !== "undefined" ? Math.min(800, window.innerWidth - 340) : 800}
|
||||
compactHeader
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
@@ -3148,102 +3254,38 @@ function EditorPageContent() {
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
{isAnyPreviewMode && previewFeaturePopupAnchor && previewFeaturePopupEntities.length > 0 ? (
|
||||
{isAnyPreviewMode && previewPinnedWikiPopupAnchor && previewPinnedWikiPopupRows.length > 0 ? (
|
||||
<div
|
||||
ref={previewPinnedWikiPopupRef}
|
||||
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
|
||||
style={{
|
||||
left: clampNumber(previewFeaturePopupAnchor.point.x + 18, 16, typeof window !== "undefined" ? window.innerWidth - 340 : previewFeaturePopupAnchor.point.x + 18),
|
||||
top: clampNumber(previewFeaturePopupAnchor.point.y - 8, 16, typeof window !== "undefined" ? window.innerHeight - 280 : previewFeaturePopupAnchor.point.y - 8),
|
||||
left: clampNumber(previewPinnedWikiPopupAnchor.point.x + 18, 16, typeof window !== "undefined" ? window.innerWidth - 340 : previewPinnedWikiPopupAnchor.point.x + 18),
|
||||
top: clampNumber(previewPinnedWikiPopupAnchor.point.y - 8, 16, typeof window !== "undefined" ? window.innerHeight - 280 : previewPinnedWikiPopupAnchor.point.y - 8),
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
|
||||
{(() => {
|
||||
// 1. Expanded entity (nested wiki selection)
|
||||
if (previewExpandedEntityId) {
|
||||
const entity = previewRelations.entitiesById[previewExpandedEntityId];
|
||||
if (!entity) return null;
|
||||
const wikis = previewRelations.entityWikisById[previewExpandedEntityId] || [];
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewExpandedEntityId(null)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-white/[0.03] text-xs text-slate-300 hover:bg-white/[0.08]"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest truncate max-w-[180px]">
|
||||
{entity.name}
|
||||
</span>
|
||||
<div className="max-h-[300px] overflow-y-auto p-3">
|
||||
<div className="grid gap-2">
|
||||
{previewPinnedWikiPopupRows.map(({ entity, wiki, quote }) => (
|
||||
<button
|
||||
key={`${entity.id}:${wiki?.id || "entity-only"}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectReplayPreviewEntity(entity.id, {
|
||||
sourceFeatureId: previewPinnedWikiPopupAnchor.featureId,
|
||||
preferredWikiId: wiki?.id,
|
||||
focusMap: false,
|
||||
selectGeometry: false,
|
||||
});
|
||||
}}
|
||||
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>
|
||||
<div className="text-sm font-bold text-white mb-2">Chọn Wiki bài viết:</div>
|
||||
<div className="grid gap-2 max-h-[200px] overflow-y-auto pr-1">
|
||||
{wikis.map((wiki) => (
|
||||
<button
|
||||
key={wiki.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectReplayPreviewEntity(entity.id, {
|
||||
sourceFeatureId: previewFeaturePopupAnchor.featureId,
|
||||
preferredWikiId: wiki.id,
|
||||
focusMap: true,
|
||||
selectGeometry: true,
|
||||
});
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
setPreviewExpandedEntityId(null);
|
||||
}}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2.5 text-left text-sm text-slate-200 transition hover:border-sky-400/40 hover:bg-sky-500/10"
|
||||
>
|
||||
📄 {wiki.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Case 1: Exactly 1 entity bound to geometry
|
||||
if (previewFeaturePopupEntities.length === 1) {
|
||||
const singleEntity = previewFeaturePopupEntities[0];
|
||||
const entityWikis = previewRelations.entityWikisById[singleEntity.id] || [];
|
||||
if (entityWikis.length === 1) {
|
||||
const singleWiki = entityWikis[0];
|
||||
const blockquoteMatch = singleWiki.content
|
||||
? singleWiki.content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/)
|
||||
: null;
|
||||
let previewSummary = blockquoteMatch ? blockquoteMatch[1].trim() : "";
|
||||
|
||||
if (!previewSummary) {
|
||||
const pMatch = singleWiki.content
|
||||
? singleWiki.content.match(/<p[^>]*>([\s\S]*?)<\/p>/)
|
||||
: null;
|
||||
previewSummary = pMatch ? pMatch[1].trim() : "";
|
||||
}
|
||||
|
||||
if (!previewSummary) {
|
||||
previewSummary = singleEntity.description || "Không có mô tả hay tóm tắt.";
|
||||
}
|
||||
const cleanSummaryText = previewSummary
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="text-base font-bold text-white mt-1">
|
||||
{singleEntity.name}
|
||||
</div>
|
||||
</div>
|
||||
{quote ? (
|
||||
<div
|
||||
className="text-sm text-slate-300 italic pl-3 leading-relaxed pr-1"
|
||||
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",
|
||||
@@ -3253,123 +3295,13 @@ function EditorPageContent() {
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{cleanSummaryText}
|
||||
{quote}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectReplayPreviewEntity(singleEntity.id, {
|
||||
sourceFeatureId: previewFeaturePopupAnchor.featureId,
|
||||
focusMap: true,
|
||||
selectGeometry: true,
|
||||
});
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
}}
|
||||
className="w-full rounded-lg bg-sky-500 hover:bg-sky-600 px-3 py-2 text-center text-xs font-semibold text-white transition shadow-lg shadow-sky-500/20"
|
||||
>
|
||||
Xem chi tiết Wiki →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (entityWikis.length > 1) {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="border-b border-white/10 pb-2 mb-2">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
Entity
|
||||
</div>
|
||||
<div className="text-base font-bold text-white">
|
||||
{singleEntity.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
Thực thể này có nhiều Wiki liên kết. Chọn để đọc:
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 max-h-[200px] overflow-y-auto pr-1">
|
||||
{entityWikis.map((wiki) => (
|
||||
<button
|
||||
key={wiki.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectReplayPreviewEntity(singleEntity.id, {
|
||||
sourceFeatureId: previewFeaturePopupAnchor.featureId,
|
||||
preferredWikiId: wiki.id,
|
||||
focusMap: true,
|
||||
selectGeometry: true,
|
||||
});
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
}}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2.5 text-left text-sm text-slate-200 transition hover:border-sky-400/40 hover:bg-sky-500/10"
|
||||
>
|
||||
📄 {wiki.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Case 2: Multiple entities bound to geometry
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">Related Entities</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
Geometry #{String(previewFeaturePopupAnchor.featureId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[252px] overflow-y-auto">
|
||||
<div className="grid gap-2 p-3">
|
||||
{previewFeaturePopupEntities.map((entity) => {
|
||||
const entityWikis = previewRelations.entityWikisById[entity.id] || [];
|
||||
return (
|
||||
<button
|
||||
key={entity.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (entityWikis.length > 1) {
|
||||
setPreviewExpandedEntityId(entity.id);
|
||||
} else {
|
||||
selectReplayPreviewEntity(entity.id, {
|
||||
sourceFeatureId: previewFeaturePopupAnchor.featureId,
|
||||
focusMap: true,
|
||||
selectGeometry: true,
|
||||
});
|
||||
setPreviewFeaturePopupAnchor(null);
|
||||
}
|
||||
}}
|
||||
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="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{entity.name}
|
||||
</div>
|
||||
{entityWikis.length > 1 ? (
|
||||
<span className="text-[10px] bg-sky-500/20 text-sky-300 px-1.5 py-0.5 rounded-full font-medium">
|
||||
{entityWikis.length} Wikis
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-xs leading-5 text-slate-400"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{entity.description?.trim() || "Không có mô tả."}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -3422,7 +3354,10 @@ function EditorPageContent() {
|
||||
key={entity.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectReplayPreviewEntity(entity.id, { preferredWikiSlug: previewLinkEntityPopup.slug });
|
||||
selectReplayPreviewEntity(entity.id, {
|
||||
preferredWikiSlug: previewLinkEntityPopup.slug,
|
||||
focusMap: false,
|
||||
});
|
||||
setPreviewLinkEntityPopup(null);
|
||||
}}
|
||||
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"
|
||||
@@ -3702,6 +3637,26 @@ function snapshotWikiToWiki(snapshot: WikiSnapshot, wikiCache: Record<string, Wi
|
||||
};
|
||||
}
|
||||
|
||||
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(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
|
||||
if (!target[key]) {
|
||||
target[key] = [value];
|
||||
|
||||
@@ -485,7 +485,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
|
||||
return { ...r, content: html };
|
||||
});
|
||||
setComparisonData(processedResults.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
setComparisonData(processedResults.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
setViewMode("compare");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh.";
|
||||
@@ -689,9 +689,9 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||
<div className="min-h-0 overflow-auto">
|
||||
{linkPreviewData?.slug === linkPreview.slug && linkPreviewData.status === "ready" ? (
|
||||
linkPreviewData.quote ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words">
|
||||
{linkPreviewData.quote}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words">
|
||||
{linkPreviewData.quote}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">No resume.</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
@@ -49,7 +49,7 @@ type Props = {
|
||||
onRemoveImageOverlay: () => void;
|
||||
};
|
||||
|
||||
export default function Editor({
|
||||
function Editor({
|
||||
mode,
|
||||
setMode,
|
||||
entityStatus,
|
||||
@@ -190,3 +190,5 @@ export default function Editor({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Editor);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setupMapLayers } from "./map/useMapLayers";
|
||||
import { useMapInteraction } from "./map/useMapInteraction";
|
||||
import { useMapSync } from "./map/useMapSync";
|
||||
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
|
||||
import { useMapHoverPopup, type MapHoverPopupContent } from "./map/useMapHoverPopup";
|
||||
|
||||
export type MapFeaturePayload = {
|
||||
featureId: string | number;
|
||||
@@ -56,6 +57,9 @@ type MapProps = {
|
||||
fitToDraftBounds?: boolean;
|
||||
fitBoundsKey?: string | number | null;
|
||||
onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
|
||||
hoverPopupEnabled?: boolean;
|
||||
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
|
||||
allowFeatureSelection?: boolean;
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
||||
@@ -93,6 +97,9 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
fitToDraftBounds = false,
|
||||
fitBoundsKey = null,
|
||||
onFeatureClick,
|
||||
hoverPopupEnabled = false,
|
||||
getHoverPopupContent,
|
||||
allowFeatureSelection = true,
|
||||
focusFeatureCollection = null,
|
||||
focusRequestKey = null,
|
||||
focusPadding,
|
||||
@@ -118,6 +125,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
const onSetModeRef = useRef(onSetMode);
|
||||
// Ref callback click feature mới nhất cho tooltip/panel ngoài map.
|
||||
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
|
||||
const getHoverPopupContentRef = useRef<MapProps["getHoverPopupContent"]>(getHoverPopupContent);
|
||||
// Ref callback create mới nhất khi drawing engine tạo feature.
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
// Ref callback add geometry global vào project mới nhất cho context menu select.
|
||||
@@ -142,6 +150,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
|
||||
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
|
||||
useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]);
|
||||
useEffect(() => { getHoverPopupContentRef.current = getHoverPopupContent; }, [getHoverPopupContent]);
|
||||
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
|
||||
useEffect(() => { onAddFeatureToProjectRef.current = onAddFeatureToProject; }, [onAddFeatureToProject]);
|
||||
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
|
||||
@@ -201,6 +210,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
onBindGeometriesRef,
|
||||
localFeatureIdsRef,
|
||||
onAddFeatureToProjectRef,
|
||||
allowFeatureSelection,
|
||||
});
|
||||
|
||||
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
|
||||
@@ -229,6 +239,13 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview",
|
||||
});
|
||||
|
||||
useMapHoverPopup({
|
||||
mapRef,
|
||||
enabled: hoverPopupEnabled,
|
||||
renderDraftRef,
|
||||
getContentRef: getHoverPopupContentRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isMapLoaded) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, memo } from "react";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
@@ -28,7 +28,7 @@ function wikiTitle(w: WikiSnapshot): string {
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
||||
function EntityWikiBindingsPanel({ setLinks }: Props) {
|
||||
const {
|
||||
entityCatalog,
|
||||
snapshotEntityRows,
|
||||
@@ -476,3 +476,5 @@ function MinusIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(EntityWikiBindingsPanel);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { useMemo, useState, memo, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
|
||||
@@ -34,7 +34,7 @@ type Props = {
|
||||
onFocusGeometry?: (geometryId: string) => void;
|
||||
};
|
||||
|
||||
export default function GeometryBindingPanel({
|
||||
function GeometryBindingPanel({
|
||||
geometries,
|
||||
selectedGeometryId,
|
||||
selectedGeometryChildIds,
|
||||
@@ -686,3 +686,5 @@ function MinusIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(GeometryBindingPanel);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import { useMemo, useState, memo, type CSSProperties } from "react";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
onDeleteEntity?: (entityId: string) => void;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({
|
||||
function ProjectEntityRefsPanel({
|
||||
onCreateEntityOnly,
|
||||
onUpdateEntity,
|
||||
hasSelectedGeometry,
|
||||
@@ -673,3 +673,5 @@ function TrashIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ProjectEntityRefsPanel);
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
dialog: DialogState | null;
|
||||
toasts: ReplayPreviewToast[];
|
||||
sidebarOpen: boolean;
|
||||
sidebarWidth?: number;
|
||||
playbackSpeed: number;
|
||||
activeStepLabel: string | null;
|
||||
activeStepNumber: number | null;
|
||||
@@ -26,6 +27,7 @@ export default function ReplayPreviewOverlay({
|
||||
dialog,
|
||||
toasts,
|
||||
sidebarOpen,
|
||||
sidebarWidth = 420,
|
||||
playbackSpeed,
|
||||
activeStepLabel,
|
||||
activeStepNumber,
|
||||
@@ -36,6 +38,7 @@ export default function ReplayPreviewOverlay({
|
||||
onExitPreview,
|
||||
}: Props) {
|
||||
const hasWikiPreview = sidebarOpen;
|
||||
const rightOffset = hasWikiPreview ? sidebarWidth + 32 : 18;
|
||||
const shouldRender =
|
||||
isPreviewMode ||
|
||||
isPlaying ||
|
||||
@@ -60,7 +63,7 @@ export default function ReplayPreviewOverlay({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 72,
|
||||
right: hasWikiPreview ? 454 : 18,
|
||||
right: rightOffset,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
width: 280,
|
||||
@@ -90,9 +93,9 @@ export default function ReplayPreviewOverlay({
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: hasWikiPreview ? 472 : 18,
|
||||
left: 18,
|
||||
right: rightOffset,
|
||||
bottom: 96,
|
||||
width: 380,
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
@@ -102,6 +105,7 @@ export default function ReplayPreviewOverlay({
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxHeight: "calc(100vh - 180px)",
|
||||
}}
|
||||
>
|
||||
{dialog.image_url?.trim() ? (
|
||||
@@ -111,7 +115,7 @@ export default function ReplayPreviewOverlay({
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "block",
|
||||
maxHeight: 220,
|
||||
maxHeight: 140,
|
||||
objectFit: "cover",
|
||||
background: "#020617",
|
||||
}}
|
||||
@@ -119,14 +123,15 @@ export default function ReplayPreviewOverlay({
|
||||
) : null}
|
||||
{dialog.text?.trim() ? (
|
||||
<div
|
||||
className="ql-editor"
|
||||
className="uhm-replay-dialog-content"
|
||||
style={{
|
||||
padding: "16px",
|
||||
color: "#f8fafc",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.6",
|
||||
overflowY: "auto",
|
||||
maxHeight: "250px",
|
||||
maxHeight: dialog.image_url?.trim() ? "180px" : "140px",
|
||||
minHeight: 0,
|
||||
background: "transparent",
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: dialog.text }}
|
||||
@@ -134,6 +139,19 @@ export default function ReplayPreviewOverlay({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<style jsx>{`
|
||||
.uhm-replay-dialog-content :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
.uhm-replay-dialog-content :global(p + p) {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.uhm-replay-dialog-content :global(ul),
|
||||
.uhm-replay-dialog-content :global(ol) {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{isPreviewMode ? (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { type CSSProperties, memo, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
onRerollGeometryId?: (oldId: string | number) => void;
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
function SelectedGeometryPanel({
|
||||
selectedFeatures,
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
@@ -466,3 +466,5 @@ function getAllowedGroupIdsForPreset(
|
||||
|
||||
return ["polygon"];
|
||||
}
|
||||
|
||||
export default memo(SelectedGeometryPanel);
|
||||
|
||||
@@ -25,6 +25,16 @@ type FeatureLabelInfo = {
|
||||
};
|
||||
const rasterBaseVisibilityGenerationByMap = new WeakMap<maplibregl.Map, number>();
|
||||
|
||||
const resolverCache = new WeakMap<
|
||||
FeatureCollection,
|
||||
Map<number | null | undefined, (feature: Feature) => string | null>
|
||||
>();
|
||||
|
||||
const featureLabelInfoCache = new WeakMap<
|
||||
Feature,
|
||||
Map<number | null | undefined, FeatureLabelInfo | null>
|
||||
>();
|
||||
|
||||
export function applyBackgroundLayerVisibility(
|
||||
map: maplibregl.Map,
|
||||
visibility: BackgroundLayerVisibility
|
||||
@@ -265,7 +275,7 @@ export function decoratePointFeaturesWithLabels(
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
const getLabel = getFeatureLabelResolver(labelContext, timelineYear);
|
||||
let changed = false;
|
||||
const nextFeatures = fc.features.map((feature) => {
|
||||
const point_label = getLabel(feature);
|
||||
@@ -289,7 +299,7 @@ export function decorateLineFeaturesWithLabels(
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
const getLabel = getFeatureLabelResolver(labelContext, timelineYear);
|
||||
let changed = false;
|
||||
const nextFeatures = fc.features.map((feature) => {
|
||||
const line_label = isLineGeometry(feature.geometry) ? getLabel(feature) : null;
|
||||
@@ -315,7 +325,7 @@ export function buildPolygonLabelFeatureCollection(
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
const getLabel = getFeatureLabelResolver(labelContext, timelineYear);
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const feature of fc.features) {
|
||||
@@ -762,6 +772,40 @@ export function roundZoom(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function getFeatureLabelResolver(
|
||||
fc: FeatureCollection,
|
||||
timelineYear?: number | null
|
||||
): (feature: Feature) => string | null {
|
||||
let yearMap = resolverCache.get(fc);
|
||||
if (!yearMap) {
|
||||
yearMap = new Map();
|
||||
resolverCache.set(fc, yearMap);
|
||||
}
|
||||
let resolver = yearMap.get(timelineYear);
|
||||
if (!resolver) {
|
||||
resolver = createFeatureLabelResolver(fc, timelineYear);
|
||||
yearMap.set(timelineYear, resolver);
|
||||
}
|
||||
return resolver;
|
||||
}
|
||||
|
||||
function getSingleEntityFeatureLabelInfoCached(
|
||||
feature: Feature,
|
||||
timelineYear?: number | null
|
||||
): FeatureLabelInfo | null {
|
||||
let yearMap = featureLabelInfoCache.get(feature);
|
||||
if (!yearMap) {
|
||||
yearMap = new Map();
|
||||
featureLabelInfoCache.set(feature, yearMap);
|
||||
}
|
||||
let info = yearMap.get(timelineYear);
|
||||
if (info === undefined) {
|
||||
info = getSingleEntityFeatureLabelInfo(feature, timelineYear);
|
||||
yearMap.set(timelineYear, info);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function createFeatureLabelResolver(
|
||||
fc: FeatureCollection,
|
||||
timelineYear?: number | null
|
||||
@@ -770,7 +814,7 @@ function createFeatureLabelResolver(
|
||||
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
|
||||
|
||||
for (const feature of fc.features) {
|
||||
const labelInfo = getSingleEntityFeatureLabelInfo(feature, timelineYear);
|
||||
const labelInfo = getSingleEntityFeatureLabelInfoCached(feature, timelineYear);
|
||||
if (!labelInfo) continue;
|
||||
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import type { Feature, FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants";
|
||||
|
||||
export type MapHoverPopupContent = {
|
||||
rows: Array<{
|
||||
title: string;
|
||||
quote?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type UseMapHoverPopupProps = {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
enabled: boolean;
|
||||
renderDraftRef: React.MutableRefObject<FeatureCollection>;
|
||||
getContentRef: React.MutableRefObject<((feature: Feature) => MapHoverPopupContent | null) | undefined>;
|
||||
};
|
||||
|
||||
export function useMapHoverPopup({
|
||||
mapRef,
|
||||
enabled,
|
||||
renderDraftRef,
|
||||
getContentRef,
|
||||
}: UseMapHoverPopupProps) {
|
||||
const enabledRef = useRef(enabled);
|
||||
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled;
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
offset: 12,
|
||||
className: "uhm-map-hover-popup",
|
||||
});
|
||||
|
||||
let hoveredId: string | null = null;
|
||||
let frameId: number | null = null;
|
||||
let pendingEvent: maplibregl.MapMouseEvent | null = null;
|
||||
|
||||
const removePopup = () => {
|
||||
hoveredId = null;
|
||||
popup.remove();
|
||||
};
|
||||
|
||||
const updatePopup = () => {
|
||||
frameId = null;
|
||||
const event = pendingEvent;
|
||||
pendingEvent = null;
|
||||
|
||||
if (!event || !enabledRef.current) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const layerIds = getHoverLayerIds(map);
|
||||
if (!layerIds.length) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const features = map.queryRenderedFeatures(event.point, { layers: layerIds }) as maplibregl.MapGeoJSONFeature[];
|
||||
if (!features.length) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedFeature = pickPreferredFeature(features);
|
||||
const rawId = renderedFeature.id ?? renderedFeature.properties?.id;
|
||||
if (rawId === undefined || rawId === null) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = String(rawId);
|
||||
const sourceFeature = renderDraftRef.current.features.find((item) => String(item.properties.id) === id);
|
||||
if (!sourceFeature) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentRef.current?.(sourceFeature) || null;
|
||||
if (!content?.rows?.some((row) => row.title.trim())) {
|
||||
removePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (id !== hoveredId) {
|
||||
hoveredId = id;
|
||||
popup.setDOMContent(buildPopupNode(content));
|
||||
}
|
||||
|
||||
popup.setLngLat(event.lngLat).addTo(map);
|
||||
stylePopupChrome(popup);
|
||||
};
|
||||
|
||||
const onMouseMove = (event: maplibregl.MapMouseEvent) => {
|
||||
pendingEvent = event;
|
||||
if (frameId !== null) return;
|
||||
frameId = window.requestAnimationFrame(updatePopup);
|
||||
};
|
||||
|
||||
const onMouseOut = () => {
|
||||
pendingEvent = null;
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
removePopup();
|
||||
};
|
||||
|
||||
map.on("mousemove", onMouseMove);
|
||||
map.on("mouseout", onMouseOut);
|
||||
map.on("dragstart", removePopup);
|
||||
map.on("zoomstart", removePopup);
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
map.off("mousemove", onMouseMove);
|
||||
map.off("mouseout", onMouseOut);
|
||||
map.off("dragstart", removePopup);
|
||||
map.off("zoomstart", removePopup);
|
||||
popup.remove();
|
||||
};
|
||||
}, [getContentRef, mapRef, renderDraftRef]);
|
||||
}
|
||||
|
||||
function getHoverLayerIds(map: maplibregl.Map): string[] {
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers) return [];
|
||||
|
||||
return style.layers
|
||||
.filter((layer) =>
|
||||
"source" in layer &&
|
||||
typeof layer.source === "string" &&
|
||||
FEATURE_STATE_SOURCE_IDS.includes(layer.source as (typeof FEATURE_STATE_SOURCE_IDS)[number])
|
||||
)
|
||||
.map((layer) => layer.id);
|
||||
}
|
||||
|
||||
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
|
||||
return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0];
|
||||
}
|
||||
|
||||
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
|
||||
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
|
||||
const geometryType = feature.geometry?.type;
|
||||
const source = typeof feature.source === "string" ? feature.source : "";
|
||||
|
||||
if (layerId.endsWith("-hit")) return 400;
|
||||
if (source === "path-arrow-shapes") return 300;
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.style.width = "320px";
|
||||
root.style.maxWidth = "calc(100vw - 2rem)";
|
||||
root.style.maxHeight = "300px";
|
||||
root.style.overflowY = "auto";
|
||||
root.style.padding = "12px";
|
||||
root.style.border = "1px solid rgba(255, 255, 255, 0.10)";
|
||||
root.style.borderRadius = "12px";
|
||||
root.style.background = "rgba(2, 6, 23, 0.95)";
|
||||
root.style.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)";
|
||||
root.style.backdropFilter = "blur(8px)";
|
||||
root.style.color = "#e2e8f0";
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "grid";
|
||||
grid.style.gap = "8px";
|
||||
root.appendChild(grid);
|
||||
|
||||
for (const row of content.rows) {
|
||||
const titleText = row.title.trim();
|
||||
if (!titleText) continue;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.style.width = "100%";
|
||||
card.style.border = "1px solid rgba(255, 255, 255, 0.10)";
|
||||
card.style.borderRadius = "8px";
|
||||
card.style.background = "rgba(255, 255, 255, 0.03)";
|
||||
card.style.padding = "12px";
|
||||
card.style.textAlign = "left";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = titleText;
|
||||
title.style.fontSize = "14px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.lineHeight = "20px";
|
||||
title.style.color = "#ffffff";
|
||||
title.style.overflow = "hidden";
|
||||
title.style.textOverflow = "ellipsis";
|
||||
title.style.whiteSpace = "nowrap";
|
||||
card.appendChild(title);
|
||||
|
||||
const quoteText = row.quote?.trim();
|
||||
if (quoteText) {
|
||||
const quote = document.createElement("div");
|
||||
quote.textContent = quoteText;
|
||||
quote.style.marginTop = "8px";
|
||||
quote.style.paddingLeft = "10px";
|
||||
quote.style.paddingRight = "4px";
|
||||
quote.style.borderLeft = "3px solid rgba(56, 189, 248, 0.40)";
|
||||
quote.style.fontSize = "14px";
|
||||
quote.style.fontStyle = "italic";
|
||||
quote.style.lineHeight = "20px";
|
||||
quote.style.color = "#cbd5e1";
|
||||
quote.style.display = "-webkit-box";
|
||||
quote.style.webkitLineClamp = "4";
|
||||
quote.style.webkitBoxOrient = "vertical";
|
||||
quote.style.overflow = "hidden";
|
||||
quote.style.whiteSpace = "normal";
|
||||
card.appendChild(quote);
|
||||
}
|
||||
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function stylePopupChrome(popup: maplibregl.Popup) {
|
||||
const element = popup.getElement();
|
||||
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
|
||||
if (content) {
|
||||
content.style.padding = "0";
|
||||
content.style.borderRadius = "12px";
|
||||
content.style.background = "transparent";
|
||||
content.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
for (const tip of Array.from(element.querySelectorAll(".maplibregl-popup-tip")) as HTMLElement[]) {
|
||||
tip.style.display = "none";
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ type UseMapInteractionProps = {
|
||||
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
|
||||
localFeatureIdsRef?: React.MutableRefObject<(string | number)[] | undefined>;
|
||||
onAddFeatureToProjectRef?: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
|
||||
allowFeatureSelection?: boolean;
|
||||
};
|
||||
|
||||
export function useMapInteraction({
|
||||
@@ -57,6 +58,7 @@ export function useMapInteraction({
|
||||
onBindGeometriesRef,
|
||||
localFeatureIdsRef,
|
||||
onAddFeatureToProjectRef,
|
||||
allowFeatureSelection = true,
|
||||
}: UseMapInteractionProps) {
|
||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
|
||||
@@ -223,7 +225,8 @@ export function useMapInteraction({
|
||||
if (!Array.isArray(localIds)) return true;
|
||||
return localIds.some((localId) => String(localId) === String(id));
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
() => allowFeatureSelection
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
|
||||
@@ -54,23 +54,27 @@ export default function TimelineBar({
|
||||
const lastTriggerTimeRef = useRef<number>(0);
|
||||
|
||||
const commitYearChange = useCallback((nextVal: number) => {
|
||||
if (nextVal === lastTriggeredYearRef.current) return;
|
||||
lastTriggeredYearRef.current = nextVal;
|
||||
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
|
||||
if (!Number.isFinite(clamped)) return;
|
||||
lastTriggeredYearRef.current = clamped;
|
||||
lastTriggerTimeRef.current = Date.now();
|
||||
onYearChangeRef.current(nextVal);
|
||||
onYearChangeRef.current(clamped);
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [lower, upper]);
|
||||
|
||||
const handleLocalYearChange = useCallback((nextVal: number) => {
|
||||
if (!Number.isFinite(nextVal)) {
|
||||
return;
|
||||
}
|
||||
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
|
||||
localYearRef.current = clamped;
|
||||
setLocalYear(clamped);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastTriggerTimeRef.current >= 1000) {
|
||||
if (now - lastTriggerTimeRef.current >= 100) {
|
||||
commitYearChange(clamped);
|
||||
} else {
|
||||
if (debounceTimerRef.current) {
|
||||
@@ -78,7 +82,7 @@ export default function TimelineBar({
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
commitYearChange(clamped);
|
||||
}, 1000);
|
||||
}, 100);
|
||||
}
|
||||
}, [lower, upper, commitYearChange]);
|
||||
|
||||
@@ -87,6 +91,12 @@ export default function TimelineBar({
|
||||
setLocalYear(null);
|
||||
}, [commitYearChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localYear !== null) return;
|
||||
localYearRef.current = safeYear;
|
||||
lastTriggeredYearRef.current = safeYear;
|
||||
}, [localYear, safeYear]);
|
||||
|
||||
const startChangingYear = (direction: number) => {
|
||||
if (effectiveDisabled) return;
|
||||
const nextVal = localYearRef.current + direction;
|
||||
@@ -163,23 +173,14 @@ export default function TimelineBar({
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<span className={styles.labelBounds}>{formatYear(lower)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={displayYear}
|
||||
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
|
||||
onMouseUp={finishLocalYearChange}
|
||||
onTouchEnd={finishLocalYearChange}
|
||||
<CanvasTimelineRuler
|
||||
year={displayYear}
|
||||
onYearChange={handleLocalYearChange}
|
||||
onYearCommit={finishLocalYearChange}
|
||||
minYear={lower}
|
||||
maxYear={upper}
|
||||
disabled={effectiveDisabled}
|
||||
className={styles.slider}
|
||||
aria-label="Timeline year"
|
||||
/>
|
||||
<span className={styles.labelBoundsRight}>
|
||||
{formatYear(upper)}
|
||||
</span>
|
||||
<div className={styles.numberWrapper}>
|
||||
<input
|
||||
type="number"
|
||||
@@ -259,3 +260,336 @@ function formatYear(year: number): string {
|
||||
}
|
||||
return `${year}`;
|
||||
}
|
||||
|
||||
interface CanvasRulerProps {
|
||||
year: number;
|
||||
onYearChange: (year: number) => void;
|
||||
onYearCommit: () => void;
|
||||
minYear: number;
|
||||
maxYear: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function CanvasTimelineRuler({
|
||||
year,
|
||||
onYearChange,
|
||||
onYearCommit,
|
||||
minYear,
|
||||
maxYear,
|
||||
disabled = false,
|
||||
}: CanvasRulerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Visible span (in years)
|
||||
const [span, setSpan] = useState(400); // default show 400 years
|
||||
|
||||
// Dimensions
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 48 });
|
||||
|
||||
// Internal tracker for current display year to decouple render lag
|
||||
const displayYearRef = useRef(year);
|
||||
|
||||
// Dragging state
|
||||
const dragRef = useRef<{
|
||||
isDragging: boolean;
|
||||
startX: number;
|
||||
startYear: number;
|
||||
hasDragged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Sync dimensions using ResizeObserver
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
if (!entries || !entries[0]) return;
|
||||
const { width, height } = entries[0].contentRect;
|
||||
setDimensions({ width, height: height || 48 });
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Draw the ruler on canvas
|
||||
const drawYear = useCallback((currentYear: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || dimensions.width === 0) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = dimensions.width;
|
||||
const height = dimensions.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Center year is the selected year
|
||||
const startYear = currentYear - span / 2;
|
||||
const endYear = currentYear + span / 2;
|
||||
|
||||
const yearToX = (y: number) => {
|
||||
return ((y - startYear) / span) * width;
|
||||
};
|
||||
|
||||
// Determine tick step based on span
|
||||
let majorStep = 100;
|
||||
let mediumStep = 10;
|
||||
let minorStep = 1;
|
||||
|
||||
if (span > 3000) {
|
||||
majorStep = 1000;
|
||||
mediumStep = 100;
|
||||
minorStep = 10;
|
||||
} else if (span > 1500) {
|
||||
majorStep = 500;
|
||||
mediumStep = 50;
|
||||
minorStep = 10;
|
||||
} else if (span > 600) {
|
||||
majorStep = 100;
|
||||
mediumStep = 20;
|
||||
minorStep = 5;
|
||||
} else if (span > 200) {
|
||||
majorStep = 100;
|
||||
mediumStep = 10;
|
||||
minorStep = 1;
|
||||
} else if (span > 60) {
|
||||
majorStep = 50;
|
||||
mediumStep = 10;
|
||||
minorStep = 1;
|
||||
} else {
|
||||
majorStep = 10;
|
||||
mediumStep = 5;
|
||||
minorStep = 1;
|
||||
}
|
||||
|
||||
// Ticks drawing bounds
|
||||
const firstMajor = Math.floor(startYear / majorStep) * majorStep;
|
||||
const lastMajor = Math.ceil(endYear / majorStep) * majorStep;
|
||||
|
||||
const pixelsPerYear = width / span;
|
||||
const showMinor = pixelsPerYear * minorStep >= 3;
|
||||
const showMedium = pixelsPerYear * mediumStep >= 5;
|
||||
|
||||
// Draw ruler track baseline
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height - 8);
|
||||
ctx.lineTo(width, height - 8);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// 1. Draw minor & medium ticks
|
||||
ctx.beginPath();
|
||||
for (let y = Math.floor(startYear); y <= Math.ceil(endYear); y++) {
|
||||
if (y < minYear || y > maxYear) continue;
|
||||
|
||||
const isMajor = y % majorStep === 0;
|
||||
const isMedium = y % mediumStep === 0;
|
||||
const isMinor = y % minorStep === 0;
|
||||
|
||||
if (isMajor) continue;
|
||||
|
||||
let tickHeight = 0;
|
||||
if (isMedium && showMedium) {
|
||||
tickHeight = 7;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
|
||||
} else if (isMinor && showMinor) {
|
||||
tickHeight = 4;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
|
||||
}
|
||||
|
||||
if (tickHeight > 0) {
|
||||
const x = yearToX(y);
|
||||
ctx.moveTo(x, height - 8);
|
||||
ctx.lineTo(x, height - 8 - tickHeight);
|
||||
}
|
||||
}
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// 2. Draw major ticks and labels
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
|
||||
ctx.font = "600 10px system-ui, -apple-system, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
for (let y = firstMajor; y <= lastMajor; y += majorStep) {
|
||||
if (y < minYear || y > maxYear) continue;
|
||||
|
||||
const x = yearToX(y);
|
||||
|
||||
// Draw tick line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, height - 8);
|
||||
ctx.lineTo(x, height - 20);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.65)";
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw label
|
||||
const label = formatYear(y);
|
||||
ctx.fillText(label, x, height - 33);
|
||||
}
|
||||
|
||||
// 3. Draw needle indicator in the center
|
||||
const needleX = width / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(needleX, 0);
|
||||
ctx.lineTo(needleX, height - 4);
|
||||
ctx.strokeStyle = "#10b981";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowColor = "rgba(16, 185, 129, 0.6)";
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw needle head triangle
|
||||
ctx.fillStyle = "#10b981";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(needleX - 5, 0);
|
||||
ctx.lineTo(needleX + 5, 0);
|
||||
ctx.lineTo(needleX, 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}, [span, dimensions, minYear, maxYear]);
|
||||
|
||||
// Redraw when dimensions change
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || dimensions.width === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = dimensions.width * dpr;
|
||||
canvas.height = dimensions.height * dpr;
|
||||
drawYear(displayYearRef.current);
|
||||
}, [dimensions, drawYear]);
|
||||
|
||||
// Redraw when span changes
|
||||
useEffect(() => {
|
||||
drawYear(displayYearRef.current);
|
||||
}, [span, drawYear]);
|
||||
|
||||
// Sync externally changed year
|
||||
useEffect(() => {
|
||||
if (!dragRef.current || !dragRef.current.isDragging) {
|
||||
displayYearRef.current = year;
|
||||
drawYear(year);
|
||||
}
|
||||
}, [year, drawYear]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
|
||||
const zoomFactor = e.deltaY > 0 ? 1.15 : 0.85;
|
||||
const nextSpan = Math.max(10, Math.min(10000, span * zoomFactor));
|
||||
setSpan(Math.round(nextSpan));
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
|
||||
dragRef.current = {
|
||||
isDragging: true,
|
||||
startX: e.clientX,
|
||||
startYear: displayYearRef.current,
|
||||
hasDragged: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!dragRef.current || !dragRef.current.isDragging) return;
|
||||
e.preventDefault();
|
||||
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
if (Math.abs(dx) > 3) {
|
||||
dragRef.current.hasDragged = true;
|
||||
}
|
||||
|
||||
const yearsPerPixel = span / dimensions.width;
|
||||
const deltaYears = -dx * yearsPerPixel;
|
||||
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
|
||||
|
||||
if (nextYear !== displayYearRef.current) {
|
||||
displayYearRef.current = nextYear;
|
||||
// Draw synchronously at 60fps
|
||||
requestAnimationFrame(() => {
|
||||
drawYear(displayYearRef.current);
|
||||
});
|
||||
onYearChange(nextYear);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
|
||||
const dragInfo = dragRef.current;
|
||||
dragRef.current = null;
|
||||
|
||||
if (!dragInfo.hasDragged) {
|
||||
// Click to jump
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clickedX = e.clientX - rect.left;
|
||||
const centerYear = displayYearRef.current;
|
||||
const startYear = centerYear - span / 2;
|
||||
const clickedYear = clampYearValue(
|
||||
Math.round(startYear + (clickedX / rect.width) * span),
|
||||
minYear,
|
||||
maxYear
|
||||
);
|
||||
displayYearRef.current = clickedYear;
|
||||
drawYear(clickedYear);
|
||||
onYearChange(clickedYear);
|
||||
}
|
||||
}
|
||||
onYearCommit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 44,
|
||||
position: "relative",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
borderRadius: 22,
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
overflow: "hidden",
|
||||
cursor: disabled ? "not-allowed" : "ew-resize",
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, memo } from "react";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
sidebarWidth?: number;
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
maxDragWidth?: number;
|
||||
compactHeader?: boolean;
|
||||
};
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
@@ -50,6 +51,7 @@ function slugifyHeading(raw: string): string {
|
||||
if (!input.length) return "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/đ/g, "d")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
@@ -122,7 +124,7 @@ function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||
return { html: doc.body.innerHTML, toc };
|
||||
}
|
||||
|
||||
export default function PublicWikiSidebar({
|
||||
function PublicWikiSidebar({
|
||||
entity,
|
||||
wiki,
|
||||
isLoading,
|
||||
@@ -132,6 +134,7 @@ export default function PublicWikiSidebar({
|
||||
sidebarWidth,
|
||||
onSidebarWidthChange,
|
||||
maxDragWidth,
|
||||
compactHeader = false,
|
||||
}: Props) {
|
||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const tocContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -311,20 +314,22 @@ export default function PublicWikiSidebar({
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
{compactHeader ? null : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "1.2px",
|
||||
fontWeight: 900,
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
Wiki
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "1.2px",
|
||||
fontWeight: 900,
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
Wiki
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
marginTop: compactHeader ? 0 : 4,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.3,
|
||||
@@ -345,7 +350,7 @@ export default function PublicWikiSidebar({
|
||||
{entity.description.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
|
||||
{!compactHeader && wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
@@ -638,3 +643,5 @@ export default function PublicWikiSidebar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PublicWikiSidebar);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, memo, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
@@ -39,7 +39,7 @@ type QuillLinkFormat = {
|
||||
__uhmOriginalSanitize?: unknown;
|
||||
};
|
||||
type QuillImageFormatCtor = {
|
||||
new (): {
|
||||
new(): {
|
||||
domNode: Element;
|
||||
format(name: string, value: string): void;
|
||||
};
|
||||
@@ -64,7 +64,7 @@ function clampTitle(title: string) {
|
||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
||||
function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
||||
const { wikis, requestedActiveId } = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
wikis: state.snapshotWikis,
|
||||
@@ -95,7 +95,7 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
|
||||
activeWikiId: string | null;
|
||||
existingHref: string | null;
|
||||
} | null>(null);
|
||||
const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => {});
|
||||
const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => { });
|
||||
const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false);
|
||||
const [wikiLinkQuery, setWikiLinkQuery] = useState("");
|
||||
const [wikiLinkError, setWikiLinkError] = useState<string | null>(null);
|
||||
@@ -285,17 +285,17 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
|
||||
|
||||
setWikiSaveError(null);
|
||||
setWikis((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id !== activeId
|
||||
? w
|
||||
: {
|
||||
...w,
|
||||
source: w.source,
|
||||
operation: w.operation === "create" ? "create" : "update",
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
doc: payload,
|
||||
}
|
||||
prev.map((w) =>
|
||||
w.id !== activeId
|
||||
? w
|
||||
: {
|
||||
...w,
|
||||
source: w.source,
|
||||
operation: w.operation === "create" ? "create" : "update",
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
doc: payload,
|
||||
}
|
||||
)
|
||||
);
|
||||
setOpen(false);
|
||||
@@ -703,122 +703,122 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
|
||||
)}
|
||||
|
||||
{collapsed ? null : (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #1e3a8a",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||
Tạo wiki mới
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsCreateOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) {
|
||||
setCreateError(null);
|
||||
setIsCheckingCreateSlug(false);
|
||||
setCreateSlugTouched(false);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
title={isCreateOpen ? "Dong" : "Mo"}
|
||||
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<>
|
||||
<input
|
||||
value={createTitle}
|
||||
onChange={(e) => {
|
||||
const nextTitle = e.target.value;
|
||||
setCreateTitle(nextTitle);
|
||||
setCreateError(null);
|
||||
if (!createSlugTouched) {
|
||||
setCreateSlug(slugifyWikiTitle(nextTitle));
|
||||
}
|
||||
}}
|
||||
placeholder="Tieu de wiki"
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={createSlug}
|
||||
onChange={(e) => {
|
||||
setCreateSlugTouched(true);
|
||||
setCreateSlug(e.target.value);
|
||||
setCreateError(null);
|
||||
}}
|
||||
placeholder="Slug"
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #1e3a8a",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||
Tạo wiki mới
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateWikiFromPanel}
|
||||
disabled={isCheckingCreateSlug}
|
||||
onClick={() =>
|
||||
setIsCreateOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) {
|
||||
setCreateError(null);
|
||||
setIsCheckingCreateSlug(false);
|
||||
setCreateSlugTouched(false);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
title={isCreateOpen ? "Dong" : "Mo"}
|
||||
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
opacity: isCheckingCreateSlug ? 0.7 : 1,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Tạo wiki mới
|
||||
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||
</button>
|
||||
{createError ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: 12 }}>
|
||||
{createError}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<>
|
||||
<input
|
||||
value={createTitle}
|
||||
onChange={(e) => {
|
||||
const nextTitle = e.target.value;
|
||||
setCreateTitle(nextTitle);
|
||||
setCreateError(null);
|
||||
if (!createSlugTouched) {
|
||||
setCreateSlug(slugifyWikiTitle(nextTitle));
|
||||
}
|
||||
}}
|
||||
placeholder="Tieu de wiki"
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={createSlug}
|
||||
onChange={(e) => {
|
||||
setCreateSlugTouched(true);
|
||||
setCreateSlug(e.target.value);
|
||||
setCreateError(null);
|
||||
}}
|
||||
placeholder="Slug"
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateWikiFromPanel}
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
opacity: isCheckingCreateSlug ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Tạo wiki mới
|
||||
</button>
|
||||
{createError ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: 12 }}>
|
||||
{createError}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -1001,11 +1001,10 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${
|
||||
w.source === "local"
|
||||
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${w.source === "local"
|
||||
? "border-emerald-300/60 text-emerald-600 dark:text-emerald-300"
|
||||
: "border-blue-300/60 text-blue-600 dark:text-blue-300"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{w.source}
|
||||
</span>
|
||||
@@ -1110,6 +1109,7 @@ function slugifyWikiTitle(raw: string): string {
|
||||
if (!input.length) return "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/đ/g, "d")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
@@ -1149,3 +1149,5 @@ function downloadTextFile(filename: string, contents: string, mime: string): voi
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export default memo(WikiSidebarPanel);
|
||||
|
||||
@@ -26,6 +26,15 @@ export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
function getFastHash(str: string | null | undefined): number {
|
||||
if (!str) return 0;
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
|
||||
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
@@ -43,7 +52,7 @@ export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefine
|
||||
source: w.source,
|
||||
title: typeof w.title === "string" ? w.title.trim() : "",
|
||||
slug: typeof w.slug === "string" ? w.slug : null,
|
||||
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
|
||||
docHash: typeof w.doc === "string" ? getFastHash(w.doc.trim()) : 0,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export function initSelect(
|
||||
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void,
|
||||
onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void,
|
||||
onAddToProject?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
isLocalFeature?: (id: string | number) => boolean
|
||||
isLocalFeature?: (id: string | number) => boolean,
|
||||
allowFeatureSelection?: () => boolean
|
||||
) {
|
||||
|
||||
const FEATURE_STATE_SOURCES = [
|
||||
@@ -85,21 +86,34 @@ export function initSelect(
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
if (!features.length) {
|
||||
if (allowFeatureSelection && !allowFeatureSelection()) {
|
||||
onFeatureClick?.(null);
|
||||
return;
|
||||
}
|
||||
clearSelection();
|
||||
onFeatureClick?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const additive = !!e.originalEvent?.altKey;
|
||||
const feature = pickPreferredFeature(features);
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
if (id === undefined || id === null) return;
|
||||
if (allowFeatureSelection && !allowFeatureSelection()) {
|
||||
onFeatureClick?.({
|
||||
featureId: id,
|
||||
point: { x: e.point.x, y: e.point.y },
|
||||
lngLat: { lng: e.lngLat.lng, lat: e.lngLat.lat },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const additive = !!e.originalEvent?.altKey;
|
||||
const didSelect = selectFeature(feature, additive);
|
||||
if (!didSelect) {
|
||||
onFeatureClick?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
if (id === undefined || id === null) return;
|
||||
onFeatureClick?.({
|
||||
featureId: id,
|
||||
point: { x: e.point.x, y: e.point.y },
|
||||
|
||||
Reference in New Issue
Block a user