diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 4682fcf..1eed43c 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -742,10 +742,12 @@ function EditorPageContent() { if (m === "replay" && featureId) { // QUY TẮC: Geo chọn đầu tiên là geo main. + const finalSelectedIds = Array.from(new Set([...selectedFeatureIds, featureId])); const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; + setReplayFeatureId(triggerId); setReplaySelection({ stageId: null, stepIndex: null }); - editor.switchReplayContext(triggerId, selectedFeatureIds); + editor.switchReplayContext(triggerId, finalSelectedIds); setSelectedFeatureIds([]); } else if (m !== "replay") { if (mode === "replay") { @@ -2037,7 +2039,13 @@ function EditorPageContent() { selectedFeatureIds={selectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds} onCreateFeature={handleCreateFeature} - onDeleteFeature={editor.deleteFeature} + onDeleteFeature={(id) => { + if (Array.isArray(id)) { + editor.deleteFeatures(id); + } else { + editor.deleteFeature(id); + } + }} onHideFeature={handleHideGeometryLocal} onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} @@ -2190,10 +2198,15 @@ function EditorPageContent() { - {selectedFeature ? ( + {selectedFeatures.length > 0 ? ( { + editor.deleteFeatures(ids); + setSelectedFeatureIds([]); + }} + onDeselectAll={() => setSelectedFeatureIds([])} changeCount={editor.changeCount} onReplayEdit={(id) => setMode("replay", id)} /> diff --git a/src/app/editor/[id]/page.tsx.orig b/src/app/editor/[id]/page.tsx.orig new file mode 100644 index 0000000..4682fcf --- /dev/null +++ b/src/app/editor/[id]/page.tsx.orig @@ -0,0 +1,2305 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useShallow } from "zustand/react/shallow"; +import Map, { type MapHandle } from "@/uhm/components/Map"; +import Editor from "@/uhm/components/Editor"; +import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; +import TimelineBar from "@/uhm/components/ui/TimelineBar"; +import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; +import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar"; +import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar"; +import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; +import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; +import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; +import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; +import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; +import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; +import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel"; +import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; +import { ApiError } from "@/uhm/api/http"; +import { fetchCurrentUser } from "@/uhm/api/auth"; +import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; +import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; +import { + Feature, + FeatureCollection, + useEditorState, +} from "@/uhm/lib/editor/state/useEditorState"; +import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; +import { + getDefaultTypeIdForFeature, + normalizeFeatureBindingIds, + normalizeFeatureEntityIds, + uniqueEntityIds, +} from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { + buildClientEntityId, + mergeEntitySearchResults, +} from "@/uhm/lib/editor/entity/entityBinding"; +import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; +import { + loadBackgroundLayerVisibilityFromStorage, +} from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; +import { deepClone } from "@/uhm/lib/editor/draft/draftDiff"; +import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; +import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; +import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; +import { + getViewportImageCoordinates, + moveImageOverlayCoordinatesByPixels, + scaleImageOverlayCoordinatesByFactor, + type MapImageOverlay, +} from "@/uhm/components/map/imageOverlay"; +import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline"; +import { useFeatureCommands } from "./featureCommands"; +import { deleteSubmission } from "@/uhm/api/projects"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; +import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; +import { + EditorStoreProvider, + useEditorStore, + useEditorStoreApi, +} from "@/uhm/store/editorStore"; +import { EditorSearchResults } from "./EditorSearchResults"; +import { ResizeHandle } from "./ResizeHandle"; +import { + clampNumber, + formatCommitTitle, + isFeatureVisibleAtYear, + normalizeEntitiesForCompare, + normalizeEntityWikiLinksForCompare, + normalizeGeoSearchBindingIds, + normalizeGeoSearchGeometry, + normalizeReplaysForCompare, + normalizeWikisForCompare, +} from "./editorPageUtils"; + +const CURRENT_YEAR = new Date().getUTCFullYear(); +const DEFAULT_EDITOR_USER_ID = "local-editor"; + +type ReplayPreviewSession = { + replay: BattleReplay; + draft: FeatureCollection; + wikis: WikiSnapshot[]; + selectedStageId: number | null; + selectedStepIndex: number | null; + timelineYear: number; + timelineFilterEnabled: boolean; + mapViewState: ReturnType; +}; + +export default function Page() { + return ( + + + + ); +} + +function EditorPageContent() { + const params = useParams(); + const router = useRouter(); + const editorStoreApi = useEditorStoreApi(); + const projectId = String(params.id || ""); + // Ref chặn auto-open lặp lại cùng project khi component re-render. + const openedProjectIdRef = useRef(null); + // Ref giữ timeout flash message của form entity để clear đúng timer cũ. + const entityFormStatusTimeoutRef = useRef(null); + // Ref giữ timeout flash message của panel geometry binding. + const geoBindingStatusTimeoutRef = useRef(null); + // Ref tracking entity tạo local để cleanup khỏi catalog nếu undo/xóa khỏi snapshot. + const localCreatedEntityIdsRef = useRef>(new Set()); + // Ref nhớ geometry vừa chọn để không xóa status khi chỉ patch metadata cùng geometry. + const lastSelectedFeatureIdRef = useRef(null); + // Ref bridge sang Map imperative API (getMap/getViewState) cho replay preview. + const mapHandleRef = useRef(null); + // State chính của editor nằm trong zustand store để các panel con đọc cùng source-of-truth. + const { + mode, + internalSetMode, + initialData, + isSaving, + isSubmitting, + isOpeningSection, + setIsOpeningSection, + commitTitle, + setCommitTitle, + activeSection, + projectState, + sectionCommits, + baselineSnapshot, + entityCatalog, + setEntityCatalog, + snapshotEntities, + setSnapshotEntities, + entityStatus, + setEntityStatus, + selectedFeatureIds, + setSelectedFeatureIds, + entityForm, + setEntityForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + geometryMetaForm, + setGeometryMetaForm, + setIsEntitySubmitting, + setEntityFormStatus, + entitySearchResults, + setEntitySearchResults, + isEntitySearchLoading, + setIsEntitySearchLoading, + timelineDraftYear, + setTimelineDraftYear, + backgroundVisibility, + setBackgroundVisibility, + isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady, + snapshotWikis, + setSnapshotWikis, + snapshotEntityWikiLinks, + setSnapshotEntityWikiLinks, + blockedPendingSubmissionId, + setBlockedPendingSubmissionId, + searchKind, + setSearchKind, + searchQuery, + setSearchQuery, + searchQueryDraft, + setSearchQueryDraft, + wikiSearchResults, + setWikiSearchResults, + isWikiSearching, + setIsWikiSearching, + geoSearchResults, + setGeoSearchResults, + isGeoSearching, + setIsGeoSearching, + setRequestedActiveWikiId, + leftPanelWidth, + setLeftPanelWidth, + rightPanelWidth, + setRightPanelWidth, + timelineFilterEnabled, + setTimelineFilterEnabled, + geometryBindingFilterEnabled, + setGeoBindingStatus, + geometryFocusRequest, + setGeometryFocusRequest, + replayFeatureId, + setReplayFeatureId, + hideOutside, + setHideOutside, + geometryVisibility, + setGeometryVisibility, + } = useEditorStore(useShallow((state) => ({ + mode: state.mode, + internalSetMode: state.setMode, + initialData: state.initialData, + isSaving: state.isSaving, + isSubmitting: state.isSubmitting, + isOpeningSection: state.isOpeningSection, + setIsOpeningSection: state.setIsOpeningSection, + commitTitle: state.commitTitle, + setCommitTitle: state.setCommitTitle, + activeSection: state.activeSection, + projectState: state.projectState, + sectionCommits: state.sectionCommits, + baselineSnapshot: state.baselineSnapshot, + entityCatalog: state.entityCatalog, + setEntityCatalog: state.setEntityCatalog, + snapshotEntities: state.snapshotEntities, + setSnapshotEntities: state.setSnapshotEntities, + entityStatus: state.entityStatus, + setEntityStatus: state.setEntityStatus, + selectedFeatureIds: state.selectedFeatureIds, + setSelectedFeatureIds: state.setSelectedFeatureIds, + entityForm: state.entityForm, + setEntityForm: state.setEntityForm, + selectedGeometryEntityIds: state.selectedGeometryEntityIds, + setSelectedGeometryEntityIds: state.setSelectedGeometryEntityIds, + geometryMetaForm: state.geometryMetaForm, + setGeometryMetaForm: state.setGeometryMetaForm, + setIsEntitySubmitting: state.setIsEntitySubmitting, + setEntityFormStatus: state.setEntityFormStatus, + entitySearchResults: state.entitySearchResults, + setEntitySearchResults: state.setEntitySearchResults, + isEntitySearchLoading: state.isEntitySearchLoading, + setIsEntitySearchLoading: state.setIsEntitySearchLoading, + timelineDraftYear: state.timelineDraftYear, + setTimelineDraftYear: state.setTimelineDraftYear, + backgroundVisibility: state.backgroundVisibility, + setBackgroundVisibility: state.setBackgroundVisibility, + isBackgroundVisibilityReady: state.isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady: state.setIsBackgroundVisibilityReady, + snapshotWikis: state.snapshotWikis, + setSnapshotWikis: state.setSnapshotWikis, + snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, + setSnapshotEntityWikiLinks: state.setSnapshotEntityWikiLinks, + blockedPendingSubmissionId: state.blockedPendingSubmissionId, + setBlockedPendingSubmissionId: state.setBlockedPendingSubmissionId, + searchKind: state.searchKind, + setSearchKind: state.setSearchKind, + searchQuery: state.searchQuery, + setSearchQuery: state.setSearchQuery, + searchQueryDraft: state.searchQueryDraft, + setSearchQueryDraft: state.setSearchQueryDraft, + wikiSearchResults: state.wikiSearchResults, + setWikiSearchResults: state.setWikiSearchResults, + isWikiSearching: state.isWikiSearching, + setIsWikiSearching: state.setIsWikiSearching, + geoSearchResults: state.geoSearchResults, + setGeoSearchResults: state.setGeoSearchResults, + isGeoSearching: state.isGeoSearching, + setIsGeoSearching: state.setIsGeoSearching, + setRequestedActiveWikiId: state.setRequestedActiveWikiId, + leftPanelWidth: state.leftPanelWidth, + setLeftPanelWidth: state.setLeftPanelWidth, + rightPanelWidth: state.rightPanelWidth, + setRightPanelWidth: state.setRightPanelWidth, + timelineFilterEnabled: state.timelineFilterEnabled, + setTimelineFilterEnabled: state.setTimelineFilterEnabled, + geometryBindingFilterEnabled: state.geometryBindingFilterEnabled, + setGeoBindingStatus: state.setGeoBindingStatus, + geometryFocusRequest: state.geometryFocusRequest, + setGeometryFocusRequest: state.setGeometryFocusRequest, + replayFeatureId: state.replayFeatureId, + setReplayFeatureId: state.setReplayFeatureId, + hideOutside: state.hideOutside, + setHideOutside: state.setHideOutside, + geometryVisibility: state.geometryVisibility, + setGeometryVisibility: state.setGeometryVisibility, + }))); + // Counter để bỏ qua response cũ khi user gõ search liên tục. + const entitySearchRequestRef = useRef(0); + const wikiSearchRequestRef = useRef(0); + const geoSearchRequestRef = useRef(0); + + // Refs mirror snapshot arrays để undo callbacks luôn đọc state mới nhất. + const snapshotEntitiesRef = useRef(snapshotEntities); + const snapshotWikisRef = useRef(snapshotWikis); + const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); + useEffect(() => { + snapshotEntitiesRef.current = snapshotEntities; + }, [snapshotEntities]); + useEffect(() => { + snapshotWikisRef.current = snapshotWikis; + }, [snapshotWikis]); + useEffect(() => { + snapshotEntityWikiLinksRef.current = snapshotEntityWikiLinks; + }, [snapshotEntityWikiLinks]); + + // Hook quản lý draft/changes/undo cho main editor và replay editor. + const editor = useEditorState(initialData, { + snapshotUndo: { + snapshotEntitiesRef, + setSnapshotEntities, + snapshotWikisRef, + setSnapshotWikis, + snapshotEntityWikiLinksRef, + setSnapshotEntityWikiLinks, + }, + initialReplays: baselineSnapshot?.replays, + mode: mode, + }); + // Setter bọc undo cho thao tác cập nhật wiki snapshot. + const setSnapshotWikisUndoable = useCallback( + (next: SetStateAction) => { + editor.setSnapshotWikis(next, "Cập nhật wiki"); + }, + [editor] + ); + // Setter bọc undo cho thao tác cập nhật binding entity-wiki. + const setSnapshotEntityWikiLinksUndoable = useCallback( + (next: SetStateAction) => { + editor.setSnapshotEntityWikiLinks(next, "Cập nhật entity-wiki"); + }, + [editor] + ); + // Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung. + const snapshotEntitiesAsEntities = useMemo(() => { + const rows = snapshotEntities || []; + return rows + .filter((e) => e && e.operation !== "delete") + .map((e) => ({ + id: String(e.id || ""), + name: String(e.name || "").trim() || String(e.id || ""), + description: e.description ?? null, + time_start: e.time_start ?? null, + time_end: e.time_end ?? null, + geometry_count: 0, + })) + .filter((e) => e.id.length > 0 && e.name.length > 0); + }, [snapshotEntities]); + + // Entity list hợp nhất giữa backend catalog và snapshot local. + const entities = useMemo( + () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), + [entityCatalog, snapshotEntitiesAsEntities] + ); + // State vị trí stage/step đang chọn trong replay editor. + const [replaySelection, setReplaySelection] = useState<{ + stageId: number | null; + stepIndex: number | null; + }>({ + stageId: null, + stepIndex: null, + }); + // State snapshot đóng băng của replay preview, tách khỏi draft đang edit. + const [previewSession, setPreviewSession] = useState(null); + // State yêu cầu autoplay sau khi chuyển vào preview mode. + const [previewAutoplayMode, setPreviewAutoplayMode] = useState<"start" | "selection" | null>(null); + // Cache wiki đã fetch trong preview để không gọi API lặp lại. + const [previewWikiCache, setPreviewWikiCache] = useState>({}); + // State lỗi riêng cho wiki preview sidebar. + const [previewWikiError, setPreviewWikiError] = useState(null); + // State loading riêng cho wiki preview sidebar. + const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false); + // State ảnh overlay local-only để vẽ trace theo ảnh mẫu. + const [imageOverlay, setImageOverlay] = useState(null); + // Bật/tắt điều khiển ảnh overlay bằng phím mũi tên và W/S. + const [imageOverlayKeyboardEnabled, setImageOverlayKeyboardEnabled] = useState(false); + // Ref giữ object URL hiện tại để revoke khi đổi/xóa ảnh, tránh leak bộ nhớ. + const imageOverlayObjectUrlRef = useRef(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 }); + }, []); + // Helper đọc MapLibre instance hiện tại cho replay dispatcher. + const getCurrentMapInstance = useCallback(() => mapHandleRef.current?.getMap() ?? null, []); + // Helper đọc camera/view hiện tại để lưu vào replay preview. + const getCurrentMapViewState = useCallback(() => mapHandleRef.current?.getViewState() ?? null, []); + const isReplayEditMode = mode === "replay"; + const isReplayPreviewMode = mode === "replay_preview"; + // Ref mirror entity list cho debounce search không phụ thuộc closure cũ. + const entitiesRef = useRef(entities); + useEffect(() => { + entitiesRef.current = entities; + }, [entities]); + + useEffect(() => { + return () => { + if (imageOverlayObjectUrlRef.current) { + URL.revokeObjectURL(imageOverlayObjectUrlRef.current); + imageOverlayObjectUrlRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (!imageOverlayKeyboardEnabled) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (isTypingTarget(event.target)) return; + + const key = event.key.toLowerCase(); + const step = event.shiftKey ? 9.6 : 2.8; + let handled = true; + setImageOverlay((prev) => { + if (!prev) return prev; + const map = getCurrentMapInstance(); + if (!map) return prev; + + if (key === "w") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, 0, -step) }; + } + if (key === "s") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, 0, step) }; + } + if (key === "a") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, -step, 0) }; + } + if (key === "d") { + return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, step, 0) }; + } + if (key === "q") { + return { + ...prev, + coordinates: scaleImageOverlayCoordinatesByFactor(map, prev.coordinates, 1.012, prev.aspectRatio), + }; + } + if (key === "e") { + return { + ...prev, + coordinates: scaleImageOverlayCoordinatesByFactor(map, prev.coordinates, 0.988, prev.aspectRatio), + }; + } + + handled = false; + return prev; + }); + + if (handled) { + event.preventDefault(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [getCurrentMapInstance, imageOverlayKeyboardEnabled]); + + useEffect(() => { + const localCreatedIds = localCreatedEntityIdsRef.current; + if (!localCreatedIds.size) return; + + const snapshotIds = new Set((snapshotEntities || []).map((entity) => String(entity.id || ""))); + setEntityCatalog((prev) => { + let changed = false; + const next = (prev || []).filter((entity) => { + const id = String(entity?.id || ""); + const shouldDrop = localCreatedIds.has(id) && !snapshotIds.has(id); + if (shouldDrop) { + changed = true; + localCreatedIds.delete(id); + return false; + } + return true; + }); + return changed ? next : prev; + }); + }, [snapshotEntities, setEntityCatalog]); + + // Clamp năm timeline vào range cố định trước khi đưa vào store. + const handleTimelineYearChange = useCallback((nextYear: number) => { + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); + }, [setTimelineDraftYear]); + + // Hook điều phối phát replay preview và các side effect lên map/UI. + const replayPreview = useReplayPreview({ + replay: previewSession?.replay || null, + draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, + getMapInstance: getCurrentMapInstance, + initialTimelineYear: previewSession?.timelineYear ?? timelineDraftYear, + initialTimelineFilterEnabled: previewSession?.timelineFilterEnabled ?? timelineFilterEnabled, + initialMapViewState: previewSession?.mapViewState ?? null, + selectedStageId: previewSession?.selectedStageId ?? replaySelection.stageId, + selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex, + onSelectStep: () => {}, + }); + + // Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay. + const replayPreviewDraft = useMemo(() => { + const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION; + if (!isReplayPreviewMode || replayPreview.hiddenGeometryIds.length === 0) { + return sourceDraft; + } + const hiddenIds = new Set(replayPreview.hiddenGeometryIds); + return { + ...sourceDraft, + features: sourceDraft.features.filter( + (feature) => !hiddenIds.has(String(feature.properties.id)) + ), + }; + }, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]); + + const activeTimelineYear = isReplayPreviewMode + ? replayPreview.timelineYear + : timelineDraftYear; + const activeTimelineFilterEnabled = isReplayPreviewMode + ? replayPreview.timelineFilterEnabled + : timelineFilterEnabled; + + // Timeline filter: only affects persisted snapshot features. + // New features created in the current session remain visible regardless of time range. + // Draft cuối cùng đưa vào map sau khi áp filter timeline. + const timelineVisibleDraft = useMemo(() => { + const activeDraft = isReplayPreviewMode + ? replayPreviewDraft + : isReplayEditMode + ? editor.replayDraft + : editor.mainDraft; + + if (!activeTimelineFilterEnabled) return activeDraft; + const year = clampYearToFixedRange(Math.trunc(activeTimelineYear)); + return { + ...activeDraft, + features: activeDraft.features.filter((feature) => { + if (!editor.hasPersistedFeature(feature.properties.id)) return true; + return isFeatureVisibleAtYear(feature, year); + }), + }; + }, [ + activeTimelineFilterEnabled, + activeTimelineYear, + editor, + isReplayEditMode, + isReplayPreviewMode, + replayPreviewDraft, + ]); + + // Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại. + const selectedFeatures = useMemo(() => { + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return []; + return selectedFeatureIds + .map(id => editor.draft.features.find(f => String(f.properties.id) === String(id))) + .filter(Boolean) as Feature[]; + }, [selectedFeatureIds, editor.draft.features]); + + // Multi-edit chỉ hợp lệ khi các geometry được chọn cùng shape type. + const isMultiEditValid = useMemo(() => { + if (selectedFeatures.length <= 1) return true; + const firstShape = selectedFeatures[0].geometry.type; + return selectedFeatures.every(f => f.geometry.type === firstShape); + }, [selectedFeatures]); + + // Feature đại diện cho panel phải; null khi multi-edit không cùng loại. + const selectedFeature = selectedFeatures.length > 0 && isMultiEditValid ? selectedFeatures[0] : null; + const selectedGeometryTime = useMemo(() => { + if (!selectedFeature) return null; + return { + time_start: selectedFeature.properties.time_start ?? null, + time_end: selectedFeature.properties.time_end ?? null, + }; + }, [selectedFeature]); + + // Choices cho panel bind geometry, gồm cả marker geometry mới tạo local. + const geometryChoices = useMemo(() => { + const createdGeometryIds = new Set(); + for (const [id, change] of editor.changes.entries()) { + if (change.action === "create") createdGeometryIds.add(String(id)); + } + const timelineVisibleGeometryIds = new Set( + timelineVisibleDraft.features.map((feature) => String(feature.properties.id)) + ); + + const rows = (editor.draft.features || []) + .filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number")) + .map((f) => { + const id = String(f.properties.id); + const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim(); + const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type; + return { + id, + label, + time_start: f.properties.time_start ?? null, + time_end: f.properties.time_end ?? null, + isTimelineVisible: timelineVisibleGeometryIds.has(id), + isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id), + }; + }); + rows.sort((a, b) => a.id.localeCompare(b.id)); + return rows; + }, [editor, timelineVisibleDraft.features]); + + // Binding ids của geometry đại diện đang chọn. + const selectedGeometryBindingIds = useMemo(() => { + if (!selectedFeature) return []; + return normalizeFeatureBindingIds(selectedFeature); + }, [selectedFeature]); + + // Choices wiki dùng trong replay actions và binding panel. + const wikiChoices = useMemo(() => { + return (snapshotWikis || []) + .filter((wiki) => wiki && wiki.operation !== "delete") + .map((wiki) => ({ + id: String(wiki.id || ""), + label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`, + })) + .filter((wiki) => wiki.id.length > 0); + }, [snapshotWikis]); + + // Dirty flag cho wiki snapshot so với baseline commit. + const wikiDirty = useMemo(() => { + const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); + const next = normalizeWikisForCompare(snapshotWikis); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [baselineSnapshot?.wikis, snapshotWikis]); + + // Dirty flag cho entity snapshot so với baseline commit. + const entitiesDirty = useMemo(() => { + const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities); + const next = normalizeEntitiesForCompare(snapshotEntities); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [baselineSnapshot?.entities, snapshotEntities]); + + // Dirty flag cho binding entity-wiki so với baseline commit. + const entityWikiDirty = useMemo(() => { + const prev = normalizeEntityWikiLinksForCompare(baselineSnapshot?.entity_wiki); + const next = normalizeEntityWikiLinksForCompare(snapshotEntityWikiLinks); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]); + + // Dirty flag cho replay scripts so với baseline commit. + const replayDirty = useMemo(() => { + const prev = normalizeReplaysForCompare(baselineSnapshot?.replays); + const next = normalizeReplaysForCompare(editor.effectiveReplays); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [baselineSnapshot?.replays, editor.effectiveReplays]); + + // Tổng số nhóm thay đổi chưa commit, dùng để enable/disable commit UI. + const pendingSaveCount = + editor.changeCount + + (wikiDirty ? 1 : 0) + + (entitiesDirty ? 1 : 0) + + (entityWikiDirty ? 1 : 0) + + (replayDirty ? 1 : 0); + // Stages của replay đang active, fallback [] để sidebar an toàn. + const activeReplayStages = useMemo( + () => editor.activeReplayDraft?.detail || [], + [editor.activeReplayDraft?.detail] + ); + + // Commands thao tác project/commit/submission dựa trên draft + store hiện tại. + const sectionCommands = useProjectCommands({ + editor, + store: editorStoreApi, + emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, + pendingSaveCount, + }); + const { + openSectionForEditing, + commitSection, + submitCurrentSection, + restoreCommit, + } = sectionCommands; + + // Thoát preview và quay về replay edit mode. + const exitReplayPreview = useCallback(() => { + replayPreview.resetPreview(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + internalSetMode("replay"); + }, [internalSetMode, replayPreview.resetPreview]); + + // Đóng băng draft/replay hiện tại thành session preview để phát thử. + const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => { + if (!editor.activeReplayDraft) return; + + setPreviewSession({ + replay: deepClone(editor.activeReplayDraft), + draft: deepClone(editor.replayDraft), + wikis: deepClone(snapshotWikis), + selectedStageId: replaySelection.stageId, + selectedStepIndex: replaySelection.stepIndex, + timelineYear: timelineDraftYear, + timelineFilterEnabled, + mapViewState: getCurrentMapViewState(), + }); + setPreviewAutoplayMode(autoplayMode); + setSelectedFeatureIds([]); + internalSetMode("replay_preview"); + }, [ + editor.activeReplayDraft, + editor.replayDraft, + getCurrentMapViewState, + internalSetMode, + replaySelection.stageId, + replaySelection.stepIndex, + setSelectedFeatureIds, + snapshotWikis, + timelineDraftYear, + timelineFilterEnabled, + ]); + + // State machine chuyển mode editor, xử lý riêng replay/replay_preview để không mất draft. + const setMode = useCallback((m: EditorMode, featureId?: string | number) => { + if (m === "replay_preview") { + return; + } + + if (mode === "replay_preview") { + replayPreview.resetPreview(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + + if (m === "replay") { + internalSetMode("replay"); + return; + } + + editor.closeReplayContext(); + setSelectedFeatureIds([]); + setReplayFeatureId(null); + setHideOutside(false); + setReplaySelection({ stageId: null, stepIndex: null }); + internalSetMode(m); + return; + } + + if (m === "replay" && featureId) { + // QUY TẮC: Geo chọn đầu tiên là geo main. + const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; + setReplayFeatureId(triggerId); + setReplaySelection({ stageId: null, stepIndex: null }); + editor.switchReplayContext(triggerId, selectedFeatureIds); + setSelectedFeatureIds([]); + } else if (m !== "replay") { + if (mode === "replay") { + editor.closeReplayContext(); + setSelectedFeatureIds([]); + } + setReplayFeatureId(null); + setHideOutside(false); + setReplaySelection({ stageId: null, stepIndex: null }); + } + internalSetMode(m); + }, [ + editor, + internalSetMode, + mode, + replayPreview.resetPreview, + selectedFeatureIds, + setHideOutside, + setReplayFeatureId, + setSelectedFeatureIds, + ]); + + useEffect(() => { + if (!activeReplayStages.length) { + if (replaySelection.stageId != null || replaySelection.stepIndex != null) { + setReplaySelection({ stageId: null, stepIndex: null }); + } + return; + } + + const targetStage = + activeReplayStages.find((stage) => stage.id === replaySelection.stageId) || + activeReplayStages[0]; + const nextStageId = targetStage.id; + let nextStepIndex: number | null = null; + + if (targetStage.steps.length > 0) { + if ( + replaySelection.stageId === targetStage.id && + replaySelection.stepIndex != null && + replaySelection.stepIndex >= 0 && + replaySelection.stepIndex < targetStage.steps.length + ) { + nextStepIndex = replaySelection.stepIndex; + } else { + nextStepIndex = 0; + } + } + + if ( + nextStageId !== replaySelection.stageId || + nextStepIndex !== replaySelection.stepIndex + ) { + setReplaySelection({ + stageId: nextStageId, + stepIndex: nextStepIndex, + }); + } + }, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]); + + useEffect(() => { + if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return; + if (previewAutoplayMode === "selection") { + replayPreview.playFromSelection(); + } else { + replayPreview.playFromStart(); + } + setPreviewAutoplayMode(null); + }, [ + isReplayPreviewMode, + previewAutoplayMode, + previewSession, + replayPreview.playFromSelection, + replayPreview.playFromStart, + ]); + + useEffect(() => { + setPreviewWikiCache({}); + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + }, [previewSession]); + + // Label ngắn cho overlay preview tại step đang phát. + const replayPreviewActiveStepLabel = useMemo(() => { + if ( + replayPreview.activeCursor.stageId == null || + replayPreview.activeCursor.stepIndex == null + ) { + return null; + } + return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`; + }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); + + const replayPreviewWikiRows = previewSession?.wikis || []; + // Wiki snapshot đang được step preview yêu cầu mở. + const replayPreviewActiveWikiSnapshot = useMemo(() => { + if (!replayPreview.activeWikiId) return null; + return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null; + }, [replayPreview.activeWikiId, replayPreviewWikiRows]); + + useEffect(() => { + if (!isReplayPreviewMode || !replayPreview.sidebarOpen) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + const activeWikiId = String(replayPreview.activeWikiId || "").trim(); + if (!activeWikiId.length) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + const localWiki = replayPreviewWikiRows.find((item) => item.id === activeWikiId) || null; + if (!localWiki) { + setPreviewWikiError("Không tìm thấy wiki trong snapshot preview."); + setIsPreviewWikiLoading(false); + return; + } + + if (typeof localWiki.doc === "string") { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + if (previewWikiCache[activeWikiId]) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + let disposed = false; + setPreviewWikiError(null); + setIsPreviewWikiLoading(true); + void fetchWikiById(activeWikiId) + .then((row) => { + if (disposed) return; + setPreviewWikiCache((prev) => ({ ...prev, [activeWikiId]: row })); + }) + .catch((err) => { + if (disposed) return; + setPreviewWikiError(err instanceof Error ? err.message : "Không tải được wiki preview."); + }) + .finally(() => { + if (!disposed) { + setIsPreviewWikiLoading(false); + } + }); + + return () => { + disposed = true; + }; + }, [ + isReplayPreviewMode, + previewWikiCache, + replayPreview.activeWikiId, + replayPreview.sidebarOpen, + replayPreviewWikiRows, + ]); + + // Wiki đầy đủ cho sidebar preview, ưu tiên doc có sẵn trong snapshot rồi mới dùng cache API. + const replayPreviewActiveWiki = useMemo(() => { + const snapshotWiki = replayPreviewActiveWikiSnapshot; + if (!snapshotWiki) return null; + if (typeof snapshotWiki.doc === "string") { + return { + id: snapshotWiki.id, + project_id: projectId, + title: snapshotWiki.title, + slug: snapshotWiki.slug ?? null, + content: snapshotWiki.doc || "", + }; + } + return previewWikiCache[snapshotWiki.id] || null; + }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); + + // Điều hướng link wiki nội bộ trong preview nhưng chỉ trong phạm vi snapshot preview. + const handleReplayPreviewWikiLinkRequest = useCallback(({ slug }: { slug: string; rect: DOMRect }) => { + const nextSlug = String(slug || "").trim(); + if (!nextSlug.length) return; + const match = replayPreviewWikiRows.find((item) => String(item.slug || "").trim() === nextSlug) || null; + if (!match) { + setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); + return; + } + setPreviewWikiError(null); + replayPreview.openWikiPanelById(match.id); + }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]); + + // Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview. + const effectiveGeometryVisibility = useMemo(() => { + const visibility: Record = { ...geometryVisibility }; + + if ((isReplayEditMode || isReplayPreviewMode) && replayFeatureId) { + // Ẩn chính geo được chọn làm replay (marker kịch bản) + visibility[String(replayFeatureId)] = false; + + if (isReplayEditMode && hideOutside) { + // Trong mode replay, ta chỉ hiển thị những gì có trong draft của replay đó + const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id))); + + // Ẩn tất cả các geo KHÔNG nằm trong draft replay hiện tại + Object.keys(visibility).forEach(fid => { + if (fid === String(replayFeatureId)) { + visibility[fid] = false; + } else { + visibility[fid] = currentReplayFeatureIds.has(fid); + } + }); + } + } + + return visibility; + }, [ + editor.draft.features, + geometryVisibility, + hideOutside, + isReplayEditMode, + isReplayPreviewMode, + replayFeatureId, + ]); + + // Load project editor payload, xử lý auth và pending-submission lock. + const openProject = useCallback(async () => { + if (!projectId) return; + try { + setIsOpeningSection(true); + setEntityStatus(null); + setBlockedPendingSubmissionId(null); + await openSectionForEditing(projectId); + setEntityStatus(null); + } catch (err) { + if (err instanceof ApiError) { + // Only bounce to login when the session is truly unauthenticated. + // Token refresh is handled centrally; if we still get 401 here, refresh likely failed/expired. + if (err.status === 401) { + router.replace("/signin"); + return; + } + // Pending submission blocks editor in BE. We parse the pending id to offer delete/unlock. + if (err.status === 409) { + try { + const payload = JSON.parse(err.body || "{}"); + if (payload?.pending_submission_id) { + setBlockedPendingSubmissionId(String(payload.pending_submission_id)); + setEntityStatus("Project đang có submission PENDING. Hãy xoa submission đó để unlock editor."); + return; + } + } catch { + // fallthrough + } + } + setEntityStatus(`Mở project thất bại: ${err.body || err.message}`); + } else { + console.error("Open project failed", err); + setEntityStatus("Mở project thất bại."); + } + } finally { + setIsOpeningSection(false); + } + }, [openSectionForEditing, projectId, router, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); + + // Xóa pending submission để backend cho phép mở editor lại. + const unlockByDeletingPendingSubmission = useCallback(async () => { + if (!blockedPendingSubmissionId) return; + const confirmed = window.confirm("Xoa submission PENDING de unlock editor? Hanh dong nay khong the hoan tac."); + if (!confirmed) return; + try { + setIsOpeningSection(true); + setEntityStatus(null); + await deleteSubmission(blockedPendingSubmissionId); + setBlockedPendingSubmissionId(null); + await openProject(); + } catch (err) { + if (err instanceof ApiError) { + setEntityStatus(`Khong the xoa submission: ${err.body || err.message}`); + } else { + setEntityStatus("Khong the xoa submission."); + } + } finally { + setIsOpeningSection(false); + } + }, [blockedPendingSubmissionId, openProject, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); + + useEffect(() => { + let disposed = false; + + async function ensureAuthenticated() { + try { + await fetchCurrentUser(); + } catch (err) { + if (disposed) return; + if (err instanceof ApiError && err.status === 401) { + // Only redirect when refresh token/session is no longer usable. + router.replace("/signin"); + return; + } + console.error("Ensure authenticated failed", err); + } + } + + ensureAuthenticated(); + return () => { + disposed = true; + }; + }, [router]); + + useEffect(() => { + if (!projectId) return; + if (openedProjectIdRef.current === projectId) return; + + openProject() + .then(() => { + openedProjectIdRef.current = projectId; + }) + .catch(() => { + // allow retry if openProject threw outside its try/catch (should be rare) + openedProjectIdRef.current = null; + }); + }, [openProject, projectId]); + + useEffect(() => { + let disposed = false; + + async function loadEntities() { + try { + const rows = await fetchEntities(); + if (disposed) return; + + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + for (const row of rows || []) { + if (!row?.id) continue; + // Prefer the freshest backend payload on conflicts. + byId.set(String(row.id), row); + } + return Array.from(byId.values()); + }); + setEntityStatus(null); + } catch (err) { + if (disposed) return; + console.error("Load entities failed", err); + setEntityStatus("Không tải được danh sách entity."); + } + } + + loadEntities(); + + return () => { + disposed = true; + }; + }, [setEntityCatalog, setEntityStatus]); + + useEffect(() => { + if (searchKind !== "entity") { + setEntitySearchResults([]); + setIsEntitySearchLoading(false); + return; + } + + const keyword = searchQuery.trim(); + if (!keyword.length) { + setEntitySearchResults([]); + setIsEntitySearchLoading(false); + return; + } + + let disposed = false; + const requestId = ++entitySearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + const keywordLower = keyword.toLowerCase(); + const localMatches = entitiesRef.current + .filter((entity) => + entity.name.toLowerCase().includes(keywordLower) || + (entity.description || "").toLowerCase().includes(keywordLower) + ) + .map((entity) => ({ + ...entity, + geometry_count: typeof entity.geometry_count === "number" ? entity.geometry_count : 0, + })); + + setIsEntitySearchLoading(true); + try { + const rows = await searchEntitiesByName(keyword, { limit: 30 }); + if (disposed || requestId !== entitySearchRequestRef.current) return; + // Centralize: merge search results into the shared entity catalog so UI stays consistent. + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + for (const row of rows || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + return Array.from(byId.values()); + }); + + const mergedRows = mergeEntitySearchResults(rows, localMatches); + setEntitySearchResults(mergedRows); + } catch (err) { + if (disposed || requestId !== entitySearchRequestRef.current) return; + console.error("Search entity by name failed", err); + setEntitySearchResults(localMatches); + } finally { + if (!disposed && requestId === entitySearchRequestRef.current) { + setIsEntitySearchLoading(false); + } + } + }, 220); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [ + searchKind, + searchQuery, + setEntityCatalog, + setEntitySearchResults, + setIsEntitySearchLoading, + ]); + + useEffect(() => { + if (searchKind !== "wiki") { + setWikiSearchResults([]); + setIsWikiSearching(false); + return; + } + + const keyword = searchQuery.trim(); + if (!keyword.length) { + setWikiSearchResults([]); + setIsWikiSearching(false); + return; + } + + let disposed = false; + const requestId = ++wikiSearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsWikiSearching(true); + try { + const rows = await searchWikisByTitle(keyword, { limit: 12 }); + if (disposed || requestId !== wikiSearchRequestRef.current) return; + setWikiSearchResults(rows); + } catch (err) { + if (disposed || requestId !== wikiSearchRequestRef.current) return; + console.error("Search wikis failed", err); + setWikiSearchResults([]); + } finally { + if (!disposed && requestId === wikiSearchRequestRef.current) { + setIsWikiSearching(false); + } + } + }, 250); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [searchKind, searchQuery, setIsWikiSearching, setWikiSearchResults]); + + useEffect(() => { + if (searchKind !== "geo") { + setGeoSearchResults([]); + setIsGeoSearching(false); + return; + } + + const keyword = searchQuery.trim(); + if (!keyword.length) { + setGeoSearchResults([]); + setIsGeoSearching(false); + return; + } + + let disposed = false; + const requestId = ++geoSearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsGeoSearching(true); + try { + const res = await searchGeometriesByEntityName(keyword, { limit: 24 }); + if (disposed || requestId !== geoSearchRequestRef.current) return; + setGeoSearchResults(res.items || []); + } catch (err) { + if (disposed || requestId !== geoSearchRequestRef.current) return; + console.error("Search geometries by entity name failed", err); + setGeoSearchResults([]); + } finally { + if (!disposed && requestId === geoSearchRequestRef.current) { + setIsGeoSearching(false); + } + } + }, 260); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [searchKind, searchQuery, setGeoSearchResults, setIsGeoSearching]); + + useEffect(() => { + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; + const stillExistIds = selectedFeatureIds.filter(id => + timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id)) + ); + if (stillExistIds.length !== selectedFeatureIds.length) { + setSelectedFeatureIds(stillExistIds); + } + }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]); + + useEffect(() => { + if (!selectedFeature) { + setSelectedGeometryEntityIds([]); + setGeometryMetaForm({ + type_key: "", + time_start: "", + time_end: "", + binding: "", + }); + setEntityFormStatus(null); + lastSelectedFeatureIdRef.current = null; + return; + } + + const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); + const nextTypeKey = typeof selectedFeature.properties.type === "string" && selectedFeature.properties.type.trim().length + ? selectedFeature.properties.type + : getDefaultTypeIdForFeature(selectedFeature); + const currentId = String(selectedFeature.properties.id); + setSelectedGeometryEntityIds(featureEntityIds); + setGeometryMetaForm({ + type_key: nextTypeKey, + time_start: selectedFeature.properties.time_start != null + ? String(selectedFeature.properties.time_start) + : "", + time_end: selectedFeature.properties.time_end != null + ? String(selectedFeature.properties.time_end) + : "", + binding: normalizeFeatureBindingIds(selectedFeature).join(", "), + }); + // Only clear status when switching to a different geometry, not when patching metadata/bindings + // on the same selected geometry (otherwise messages will blink). + if (lastSelectedFeatureIdRef.current !== currentId) { + setEntityFormStatus(null); + } + lastSelectedFeatureIdRef.current = currentId; + }, [ + selectedFeature, + setEntityFormStatus, + setGeometryMetaForm, + setSelectedGeometryEntityIds, + ]); + + // Hiển thị status form entity trong thời gian ngắn, tự clear timer cũ. + const flashEntityFormStatus = useCallback((msg: string | null, timeoutMs = 3000) => { + if (entityFormStatusTimeoutRef.current) { + window.clearTimeout(entityFormStatusTimeoutRef.current); + entityFormStatusTimeoutRef.current = null; + } + setEntityFormStatus(msg); + if (msg && timeoutMs > 0) { + entityFormStatusTimeoutRef.current = window.setTimeout(() => { + setEntityFormStatus(null); + entityFormStatusTimeoutRef.current = null; + }, timeoutMs); + } + }, [setEntityFormStatus]); + + // Hiển thị status binding geometry trong thời gian ngắn, tự clear timer cũ. + const flashGeoBindingStatus = useCallback((msg: string | null, timeoutMs = 3000) => { + if (geoBindingStatusTimeoutRef.current) { + window.clearTimeout(geoBindingStatusTimeoutRef.current); + geoBindingStatusTimeoutRef.current = null; + } + setGeoBindingStatus(msg); + if (msg && timeoutMs > 0) { + geoBindingStatusTimeoutRef.current = window.setTimeout(() => { + setGeoBindingStatus(null); + geoBindingStatusTimeoutRef.current = null; + }, timeoutMs); + } + }, [setGeoBindingStatus]); + + useEffect(() => { + setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); + setIsBackgroundVisibilityReady(true); + }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); + + // Thêm entity backend vào snapshot project dưới dạng reference. + const handleAddEntityRefToProject = useCallback((entity: Entity) => { + const id = String(entity.id || "").trim(); + if (!id) return; + editor.setSnapshotEntities((prev) => { + if (prev.some((e) => String(e.id) === id)) return prev; + return [ + { + id, + source: "ref", + operation: "reference", + name: entity.name, + description: entity.description ?? null, + time_start: entity.time_start ?? null, + time_end: entity.time_end ?? null, + }, + ...prev, + ]; + }, `Thêm entity ref #${id}`); + // Keep entity catalog centralized as a single in-memory list. + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + byId.set(id, entity); + return Array.from(byId.values()); + }); + }, [editor, setEntityCatalog]); + + // Cập nhật metadata entity trong snapshot project, có undo qua editor state. + const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => { + const id = String(entityId || "").trim(); + if (!id) return; + const nextName = String(payload?.name || "").trim(); + if (!nextName.length) { + flashEntityFormStatus("Ten entity la bat buoc."); + return; + } + const nextDescription = payload?.description == null ? null : String(payload.description); + let nextTimeStart: number | undefined; + let nextTimeEnd: number | undefined; + try { + nextTimeStart = parseOptionalEntityYearInput(payload.time_start, "time_start"); + nextTimeEnd = parseOptionalEntityYearInput(payload.time_end, "time_end"); + if (nextTimeStart != null && nextTimeEnd != null && nextTimeStart > nextTimeEnd) { + flashEntityFormStatus("time_start phải <= time_end."); + return; + } + } catch (err) { + flashEntityFormStatus(err instanceof Error ? err.message : "Năm entity không hợp lệ."); + return; + } + + editor.setSnapshotEntities((prev) => prev.map((e) => { + if (!e || String(e.id) !== id) return e; + const source = e.source === "inline" ? "inline" : "ref"; + const operation = + source === "ref" + ? "reference" + : e.operation === "create" + ? "create" + : "update"; + return { + ...e, + id, + source, + operation, + name: nextName, + description: nextDescription, + time_start: nextTimeStart, + time_end: nextTimeEnd, + }; + }), `Cap nhat entity #${id}`); + flashEntityFormStatus("Da cap nhat entity. Commit khi san sang.", 3000); + }, [editor, flashEntityFormStatus]); + + // Bind/unbind entity vào toàn bộ selected geometry hợp lệ. + const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { + if (!selectedFeatures || selectedFeatures.length === 0) { + flashEntityFormStatus("Chưa chọn geometry để bind entity."); + return; + } + if (!isMultiEditValid) { + flashEntityFormStatus("Không thể bind entity cho nhiều geometry khác loại."); + return; + } + const id = String(entityId || "").trim(); + if (!id) return; + const nextEntityIds = (() => { + const prev = selectedGeometryEntityIds; + const has = prev.includes(id); + if (nextChecked) { + if (has) return prev; + return uniqueEntityIds([...prev, id]); + } + if (!has) return prev; + return prev.filter((x) => x !== id); + })(); + + setIsEntitySubmitting(true); + flashEntityFormStatus(null, 0); + try { + editor.patchFeaturePropertiesBatch( + selectedFeatures.map((feature) => ({ + id: feature.properties.id, + patch: buildFeatureEntityPatch(feature, nextEntityIds, entities), + })), + nextChecked ? "Bind entity vào GEO" : "Unbind entity khỏi GEO" + ); + setSelectedGeometryEntityIds(nextEntityIds); + flashEntityFormStatus( + nextChecked + ? "Đã bind entity vào geometry. Commit khi sẵn sàng." + : "Đã unbind entity khỏi geometry. Commit khi sẵn sàng.", + 3000 + ); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + entities, + flashEntityFormStatus, + selectedFeatures, + isMultiEditValid, + selectedGeometryEntityIds, + setIsEntitySubmitting, + setSelectedGeometryEntityIds, + ]); + + // Bind/unbind geometry id vào trường binding của selected geometry. + const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { + if (!selectedFeatures || selectedFeatures.length === 0) { + flashGeoBindingStatus("Chưa chọn geometry để bind."); + return; + } + if (!isMultiEditValid) { + flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại."); + return; + } + const id = String(geoId || "").trim(); + if (!id) return; + if (selectedFeatures.some(f => String(f.properties.id) === id)) return; + + + + setIsEntitySubmitting(true); + flashGeoBindingStatus(null, 0); + try { + const bindingPatches = selectedFeatures.map((feature) => { + const prevBindingIds = normalizeFeatureBindingIds(feature); + const has = prevBindingIds.includes(id); + const nextBindingIds = (() => { + if (nextChecked) { + if (has) return prevBindingIds; + return [...prevBindingIds, id]; + } + if (!has) return prevBindingIds; + return prevBindingIds.filter((x) => x !== id); + })(); + return { + id: feature.properties.id, + patch: { binding: nextBindingIds }, + }; + }); + editor.patchFeaturePropertiesBatch( + bindingPatches, + nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" + ); + + // Assume selectedFeature (the first one) reflects the representative binding in UI + const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); + const firstFeatureHas = firstFeaturePrevBindings.includes(id); + const nextBindingIdsForUI = (() => { + if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id]; + return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings; + })(); + setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") })); + flashGeoBindingStatus( + nextChecked + ? "Đã bind geometry vào binding. Commit khi sẵn sàng." + : "Đã gỡ binding geometry. Commit khi sẵn sàng.", + 3000 + ); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + flashGeoBindingStatus, + selectedFeatures, + isMultiEditValid, + setGeometryMetaForm, + setIsEntitySubmitting, + ]); + + // Bind nhiều geometries vào target geometry. + const handleBindGeometries = useCallback((targetId: string | number, sourceIds: (string | number)[]) => { + const idStr = String(targetId).trim(); + if (!idStr) return; + + const targetFeature = editor.draft.features.find((f) => String(f.properties.id) === idStr); + if (!targetFeature) { + flashGeoBindingStatus("Không tìm thấy geometry đích."); + return; + } + + const prevBindingIds = normalizeFeatureBindingIds(targetFeature); + + // Merge prevBindingIds with sourceIds (which are strings of selected features) + // filter out targetId itself (we can't bind a geometry to itself) + const newSources = sourceIds.map(String).filter((x) => x !== idStr); + const merged = Array.from(new Set([...prevBindingIds, ...newSources])); + + editor.patchFeaturePropertiesBatch( + [{ + id: targetFeature.properties.id, + patch: { binding: merged }, + }], + "Bind các geometry đã chọn vào GEO" + ); + + setSelectedFeatureIds([targetFeature.properties.id]); + flashGeoBindingStatus(`Đã bind ${newSources.length} geometry vào GEO này. Commit khi sẵn sàng.`, 3000); + }, [editor, flashGeoBindingStatus, setSelectedFeatureIds]); + + // Focus/zoom tới geometry từ binding panel; nếu geo có time_start thì kéo year filter về năm đó. + const handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => { + const id = String(geoId || "").trim(); + if (!id) return; + + const feature = editor.draft.features.find((item) => String(item.properties.id) === id) || null; + if (!feature) { + flashGeoBindingStatus("Không tìm thấy geometry để zoom."); + return; + } + + const geoTimeStart = feature.properties.time_start; + if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) { + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart))); + } + + setSelectedFeatureIds([feature.properties.id]); + setGeometryFocusRequest((prev) => ({ + key: (prev?.key ?? 0) + 1, + collection: { + type: "FeatureCollection", + features: [feature], + }, + })); + }, [ + editor.draft.features, + flashGeoBindingStatus, + setGeometryFocusRequest, + setSelectedFeatureIds, + setTimelineDraftYear, + ]); + + const handleHideGeometryLocal = useCallback((geoId: string | number) => { + const id = String(geoId || "").trim(); + if (!id) return; + setGeometryVisibility((prev) => ({ + ...prev, + [id]: false, + })); + setSelectedFeatureIds((prev) => prev.filter((item) => String(item) !== id)); + }, [setGeometryVisibility, setSelectedFeatureIds]); + + // Thêm wiki backend vào snapshot project dưới dạng reference. + const handleAddWikiRefToProject = useCallback((wiki: Wiki) => { + const id = String(wiki.id || "").trim(); + if (!id) return; + const title = (wiki.title || "").trim() || "Untitled wiki"; + editor.setSnapshotWikis((prev) => { + if (prev.some((w) => w.id === id)) return prev; + return [ + { + id, + source: "ref", + operation: "reference", + title, + doc: null, + }, + ...prev, + ]; + }, `Thêm wiki ref #${id}`); + setRequestedActiveWikiId(id); + }, [editor, setRequestedActiveWikiId]); + + // Tạo image overlay từ file local, mặc định phủ theo viewport map hiện tại. + const handlePickImageOverlay = useCallback((file: File | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setEntityStatus("File overlay phải là ảnh."); + return; + } + + const map = getCurrentMapInstance(); + if (!map) { + setEntityStatus("Map chưa sẵn sàng để thêm ảnh overlay."); + return; + } + + const nextUrl = URL.createObjectURL(file); + void readImageAspectRatio(nextUrl) + .then((aspectRatio) => { + const previousUrl = imageOverlayObjectUrlRef.current; + imageOverlayObjectUrlRef.current = nextUrl; + setImageOverlay((prev) => ({ + url: nextUrl, + name: file.name || "Trace image", + opacity: prev?.opacity ?? 0.55, + aspectRatio, + coordinates: getViewportImageCoordinates(map, aspectRatio), + })); + if (previousUrl) { + URL.revokeObjectURL(previousUrl); + } + }) + .catch((err) => { + console.error("Read image size failed", err); + URL.revokeObjectURL(nextUrl); + setEntityStatus("Không đọc được kích thước ảnh overlay."); + }); + }, [getCurrentMapInstance, setEntityStatus]); + + // Đọc ảnh trực tiếp từ clipboard và dùng làm overlay trace. + const handlePasteImageOverlay = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.clipboard?.read) { + setEntityStatus("Trình duyệt không hỗ trợ paste ảnh từ clipboard."); + return; + } + + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find((type) => type.startsWith("image/")); + if (!imageType) continue; + const blob = await item.getType(imageType); + const extension = imageType.split("/")[1] || "png"; + const file = new File([blob], `clipboard-image.${extension}`, { type: imageType }); + handlePickImageOverlay(file); + return; + } + setEntityStatus("Clipboard không có ảnh để paste."); + } catch (err) { + console.error("Paste image overlay failed", err); + setEntityStatus("Không paste được ảnh. Hãy cấp quyền clipboard hoặc dùng nút Thêm ảnh."); + } + }, [handlePickImageOverlay, setEntityStatus]); + + // Chỉnh opacity của image overlay mà không đổi vị trí/ảnh. + const handleImageOverlayOpacityChange = useCallback((opacity: number) => { + const nextOpacity = Number.isFinite(opacity) + ? Math.max(0, Math.min(1, opacity)) + : 0.55; + setImageOverlay((prev) => prev ? { ...prev, opacity: nextOpacity } : prev); + }, []); + + // Xóa image overlay khỏi map và revoke object URL local. + const handleRemoveImageOverlay = useCallback(() => { + if (imageOverlayObjectUrlRef.current) { + URL.revokeObjectURL(imageOverlayObjectUrlRef.current); + imageOverlayObjectUrlRef.current = null; + } + setImageOverlay(null); + setImageOverlayKeyboardEnabled(false); + }, []); + + // Import geometry từ kết quả search GEO vào draft hiện tại và bind entity liên quan. + const handleImportGeoFromSearch = useCallback(( + entityItem: EntityGeometriesSearchItem, + geo: EntityGeometrySearchGeo + ) => { + const geoId = String(geo?.id || "").trim(); + if (!geoId) return; + + // Ensure the geometry stays selectable even if it doesn't match the current timeline year. + setTimelineFilterEnabled(false); + + const importedEntity: Entity = { + id: entityItem.entity_id, + name: (entityItem.name || "").trim() || entityItem.entity_id, + description: (entityItem.description || "").trim() || null, + geometry_count: 0, + }; + + const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null; + if (existing) { + // Keep entity store consistent: importing/selecting a geo implies the entity should exist in snapshot + catalog. + handleAddEntityRefToProject(importedEntity); + setSelectedFeatureIds([existing.properties.id]); + flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000); + return; + } + + const geometry = normalizeGeoSearchGeometry(geo.draw_geometry); + if (!geometry) { + flashEntityFormStatus("Không import được: draw_geometry không hợp lệ.", 3000); + return; + } + + const bindingIds = normalizeGeoSearchBindingIds(geo.binding); + const typeKey = geo.type || null; + + const feature: Feature = { + type: "Feature", + properties: { + id: geoId, + type: typeKey, + time_start: typeof geo.time_start === "number" ? geo.time_start : null, + time_end: typeof geo.time_end === "number" ? geo.time_end : null, + binding: bindingIds.length ? bindingIds : undefined, + entity_id: entityItem.entity_id, + entity_ids: [entityItem.entity_id], + entity_name: (entityItem.name || "").trim() || entityItem.entity_id, + entity_names: [(entityItem.name || "").trim() || entityItem.entity_id], + }, + geometry, + }; + + editor.createFeatureWithSnapshotEntities( + feature, + (prev) => { + if (prev.some((e) => String(e.id) === importedEntity.id)) return prev; + return [ + { + id: importedEntity.id, + source: "ref", + operation: "reference", + name: importedEntity.name, + description: importedEntity.description ?? null, + }, + ...prev, + ]; + }, + `Import GEO #${geoId}` + ); + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + byId.set(importedEntity.id, importedEntity); + return Array.from(byId.values()); + }); + setSelectedFeatureIds([feature.properties.id]); + flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000); + }, [ + editor, + flashEntityFormStatus, + handleAddEntityRefToProject, + setEntityCatalog, + setSelectedFeatureIds, + setTimelineFilterEnabled, + ]); + + // Commands thao tác metadata/entity binding cho feature đang chọn. + const featureCommands = useFeatureCommands({ + editor, + selectedFeatures, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + }); + + // Tạo entity inline chỉ trong snapshot local, chưa gọi backend cho tới khi commit. + const handleCreateEntityOnly = async () => { + const name = entityForm.name.trim(); + if (!name) { + setEntityFormStatus("Tên entity là bắt buộc."); + return; + } + + const description = entityForm.description.trim() || null; + let timeStart: number | undefined; + let timeEnd: number | undefined; + try { + timeStart = parseOptionalEntityYearInput(entityForm.time_start, "time_start"); + timeEnd = parseOptionalEntityYearInput(entityForm.time_end, "time_end"); + if (timeStart != null && timeEnd != null && timeStart > timeEnd) { + setEntityFormStatus("time_start phải <= time_end."); + return; + } + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Năm entity không hợp lệ."); + return; + } + const normalizedName = name.toLowerCase(); + const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName); + if (duplicatedName) { + setEntityFormStatus("Tên entity đã tồn tại."); + return; + } + + const entityId = buildClientEntityId(); + const createdEntity: Entity = { + id: entityId, + name, + description, + time_start: timeStart ?? null, + time_end: timeEnd ?? null, + geometry_count: 0, + }; + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + editor.setSnapshotEntities((prev) => { + if (prev.some((e) => String(e.id) === entityId)) return prev; + return [ + { + id: entityId, + source: "inline", + operation: "create", + name, + description, + time_start: timeStart, + time_end: timeEnd, + }, + ...prev, + ]; + }, `Tạo entity #${entityId}`); + localCreatedEntityIdsRef.current.add(entityId); + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + byId.set(entityId, createdEntity); + return Array.from(byId.values()); + }); + + setEntityForm((prev) => ({ + ...prev, + name: "", + description: "", + time_start: "", + time_end: "", + })); + setEntityStatus(null); + setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng."); + } finally { + setIsEntitySubmitting(false); + } + }; + + // Commit head hiện tại để hiển thị label lịch sử. + const headCommit = projectState?.head_commit_id + ? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || null + : null; + + // Tạo geometry từ map engine rồi select ngay geometry mới. + const handleCreateFeature = (feature: Feature) => { + editor.createFeature(feature); + setSelectedFeatureIds([feature.properties.id]); + }; + + // Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng. + const mapLabelSourceDraft = isReplayPreviewMode + ? previewSession?.draft || EMPTY_FEATURE_COLLECTION + : editor.draft; + const mapLabelContextDraft = useMemo( + () => buildEntityLabelContextDraft(mapLabelSourceDraft, entities), + [entities, mapLabelSourceDraft] + ); + + return ( +
+ {!isReplayEditMode && !isReplayPreviewMode ? ( + <> + + + { + setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); + }} + /> + + ) : isReplayEditMode ? ( + <> + setMode("select")} + isPreviewPlaying={false} + previewPlaybackSpeed={1} + onPlayPreviewFromStart={() => openReplayPreview("start")} + onPlayPreviewFromSelection={() => openReplayPreview("selection")} + onStopPreview={() => {}} + onResetPreview={() => {}} + /> + { + setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); + }} + /> + + ) : null} + + {blockedPendingSubmissionId ? ( +
+
+

Editor dang bi khoa

+
+ Project nay dang co submission o trang thai PENDING (id:{" "} + {blockedPendingSubmissionId}). Theo BE moi, khi + submission dang pending thi khong duoc tao submission/commit moi va khong duoc vao editor. +
+
+ + +
+
+
+ ) : null} + + {!blockedPendingSubmissionId ? ( +
+ {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )} + {isReplayPreviewMode ? ( + + ) : null} + {isReplayPreviewMode && replayPreview.sidebarOpen ? ( + + ) : null} + {!isReplayPreviewMode || replayPreview.timelineVisible ? ( + + ) : null} +
+ ) : null} + + {!isReplayEditMode && !isReplayPreviewMode ? ( + <> + { + // dragging handle (between map and right panel): moving right increases right panel width + setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); + }} + /> + + + { + setSearchKind(next); + setSearchQuery(""); + setSearchQueryDraft(""); + }} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + onLocalSearchQueryChange={setSearchQueryDraft} + searchQueryDraft={searchQueryDraft} + entitySearchResults={entitySearchResults} + isEntitySearchLoading={isEntitySearchLoading} + onAddEntityRefToProject={handleAddEntityRefToProject} + wikiSearchResults={wikiSearchResults} + isWikiSearching={isWikiSearching} + onAddWikiRefToProject={handleAddWikiRefToProject} + geoSearchResults={geoSearchResults} + isGeoSearching={isGeoSearching} + onImportGeoFromSearch={handleImportGeoFromSearch} + /> + + + + + + + + + {selectedFeature ? ( + setMode("replay", id)} + /> + ) : null} +
+ } + /> + + ) : isReplayEditMode ? ( + <> + { + setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); + }} + /> + String(id))} + currentTimelineYear={timelineDraftYear} + geometryChoices={geometryChoices} + wikiChoices={wikiChoices} + getCurrentMapViewState={getCurrentMapViewState} + onMutateReplay={editor.mutateActiveReplay} + /> + + ) : null} +
+ ); +} + +function readImageAspectRatio(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + if (!width || !height) { + reject(new Error("Image has invalid dimensions.")); + return; + } + resolve(width / height); + }; + image.onerror = () => reject(new Error("Image load failed.")); + image.src = url; + }); +} + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tagName = target.tagName.toLowerCase(); + return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable; +} + +function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity[]): FeatureCollection { + if (!draft.features.length) return draft; + + const entityById = new globalThis.Map(); + for (const entity of entities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + entityById.set(id, entity); + } + + return { + ...draft, + features: draft.features.map((feature) => { + const entityIds = normalizeFeatureEntityIds(feature); + if (!entityIds.length) return feature; + + const candidates = entityIds.map((id) => { + const entity = entityById.get(id) || null; + const name = String(entity?.name || id).trim(); + if (!name) return null; + return { + id, + name, + time_start: entity?.time_start ?? null, + time_end: entity?.time_end ?? null, + }; + }).filter((candidate) => candidate !== null); + + return { + ...feature, + properties: { + ...feature.properties, + entity_id: entityIds[0] || null, + entity_ids: entityIds, + entity_name: candidates[0]?.name || null, + entity_names: candidates.map((candidate) => candidate.name), + entity_label_candidates: candidates, + }, + }; + }), + }; +} + +function parseOptionalEntityYearInput(value: string, fieldName: string): number | undefined { + const trimmed = String(value || "").trim(); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error(`${fieldName} phải là số nguyên.`); + } + return parsed; +} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 7d25088..707df34 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -42,7 +42,7 @@ type MapProps = { labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; - onDeleteFeature?: (id: string | number) => void; + onDeleteFeature?: (id: string | number | (string | number)[]) => void; onHideFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; allowGeometryEditing?: boolean; diff --git a/src/uhm/components/editor/SelectedGeometryPanel.tsx b/src/uhm/components/editor/SelectedGeometryPanel.tsx index 212bef3..bbaa7cb 100644 --- a/src/uhm/components/editor/SelectedGeometryPanel.tsx +++ b/src/uhm/components/editor/SelectedGeometryPanel.tsx @@ -18,6 +18,8 @@ type Props = { onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>; changeCount: number; onReplayEdit?: (id: string | number) => void; + onDeleteFeatures?: (ids: (string | number)[]) => void; + onDeselectAll?: () => void; }; export default function SelectedGeometryPanel({ @@ -25,6 +27,8 @@ export default function SelectedGeometryPanel({ onApplyGeometryMetadata, changeCount, onReplayEdit, + onDeleteFeatures, + onDeselectAll, }: Props) { const { geometryMetaForm, @@ -74,6 +78,13 @@ export default function SelectedGeometryPanel({ const visibleGeoApplyFeedback = geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null; + const isBulkMode = selectedFeatures.length >= 2; + const isMultiEditValid = useMemo(() => { + if (selectedFeatures.length <= 1) return true; + const firstShape = selectedFeatures[0].geometry.type; + return selectedFeatures.every((f) => f.geometry.type === firstShape); + }, [selectedFeatures]); + if (!selectedFeatures || selectedFeatures.length === 0) return null; const representativeFeature = selectedFeatures[0]; @@ -99,7 +110,7 @@ export default function SelectedGeometryPanel({ >
- Geometry property + {isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
+ + +
+ + )} +
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
-
- Loại GEO -
- + setGeometryMetaForm((prev) => ({ + ...prev, + type_key: event.target.value, + })) + } + disabled={isEntitySubmitting} + style={entityInputStyle} > - {group.options.map((option) => ( - + ) : null} + {groupedGeoTypeOptions.map((group) => ( + + {group.options.map((option) => ( + + ))} + ))} - - ))} - - {selectedTypeOption ? ( -
- Đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) -
- ) : geometryMetaForm.type_key ? ( -
- Đang chọn: {geometryMetaForm.type_key} -
- ) : null} - - setGeometryMetaForm((prev) => ({ - ...prev, - time_start: event.target.value, - })) - } - placeholder="time_start" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - - setGeometryMetaForm((prev) => ({ - ...prev, - time_end: event.target.value, - })) - } - placeholder="time_end" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - {/* onGeometryMetaFormChange("binding", event.target.value)}*/} - {/* placeholder="binding (geometry ids, comma separated)"*/} - {/* disabled={isEntitySubmitting}*/} - {/* style={entityInputStyle}*/} - {/*/>*/} - - {onReplayEdit && selectedFeatures.length > 0 && ( - + + {selectedTypeOption ? ( +
+ Đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) +
+ ) : geometryMetaForm.type_key ? ( +
+ Đang chọn: {geometryMetaForm.type_key} +
+ ) : null} + + setGeometryMetaForm((prev) => ({ + ...prev, + time_start: event.target.value, + })) + } + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + setGeometryMetaForm((prev) => ({ + ...prev, + time_end: event.target.value, + })) + } + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + {onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && ( + + )} + {visibleGeoApplyFeedback ? ( +
+ {visibleGeoApplyFeedback.text} +
+ ) : null} + )} - {visibleGeoApplyFeedback ? ( -
- {visibleGeoApplyFeedback.text} -
- ) : null} {changeCount > 0 ? ( @@ -254,7 +338,7 @@ export default function SelectedGeometryPanel({ Thay đổi sẽ vào lịch sử khi Commit. ) : null} - + )} ); diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index e581ab3..c5226d9 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -29,7 +29,7 @@ type UseMapInteractionProps = { onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>; onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>; onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>; - onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>; + onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>; onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; @@ -144,7 +144,7 @@ export function useMapInteraction({ map, () => modeRef.current, allowGeometryEditing - ? (id: string | number) => { + ? (id: string | number | (string | number)[]) => { editingEngineRef.current?.clearEditing(); onSelectFeatureIdsRef.current?.([]); onDeleteRef.current?.(id); diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index 2e2a784..ac1e454 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -540,6 +540,33 @@ export function useEditorState( commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); } + function deleteFeatures(ids: Array) { + if (mode === "replay") { + return; + } + + const idsSet = new Set(ids.map(String)); + const nextFeatures: Feature[] = []; + const undoActions: UndoAction[] = []; + + for (const feature of mainDraftRef.current.features) { + if (idsSet.has(String(feature.properties.id))) { + undoActions.push({ type: "delete", feature: deepClone(feature) }); + } else { + nextFeatures.push(feature); + } + } + + if (undoActions.length === 0) return; + + pushMainUndo( + undoActions.length === 1 + ? undoActions[0] + : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: undoActions } + ); + commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); + } + function buildPayload(): Change[] { return Array.from(changes.values()).map((change) => deepClone(change)); } @@ -695,6 +722,7 @@ export function useEditorState( patchFeaturePropertiesBatch, updateFeature, deleteFeature, + deleteFeatures, undo, buildPayload, clearChanges, diff --git a/src/uhm/lib/map/engines/selectingEngine.ts b/src/uhm/lib/map/engines/selectingEngine.ts index c37d700..4fdbfb2 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -5,7 +5,7 @@ import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; export function initSelect( map: maplibregl.Map, getMode: ModeGetter, - onDelete?: (id: string | number) => void, + onDelete?: (id: string | number | (string | number)[]) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onDuplicate?: (id: string | number) => void, onHide?: (id: string | number) => void, @@ -44,10 +44,13 @@ export function initSelect( clearSelection(); } - if (additive && selectedIds.has(id)) { + const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id)); + const isAlreadySelected = idToRemove !== undefined; + + if (additive && isAlreadySelected) { // Alt + click on an already selected feature removes it from the selection - setSelectionStateForId(id, false); - selectedIds.delete(id); + setSelectionStateForId(idToRemove, false); + selectedIds.delete(idToRemove); onSelectIds?.(Array.from(selectedIds)); return; } @@ -98,7 +101,7 @@ export function initSelect( const id = feature.id ?? feature.properties?.id; if (id === undefined || id === null) return; - const isRightClickedItemAlreadySelected = selectedIds.has(id); + const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id)); const hasSelection = selectedIds.size > 0; // If the right-clicked item is not selected, and there is no active selection, @@ -258,26 +261,30 @@ export function initSelect( }; const selectedCount = selectedIds.size; - let hasMenuItems = false; + const effectiveCount = selectedCount || 1; + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; - if (!isRightClickedItemAlreadySelected && hasSelection) { - if (onBindGeometries) { - const targetId = clickedFeature.id ?? clickedFeature.properties?.id; - if (targetId !== undefined && targetId !== null) { - const sourceIds = Array.from(selectedIds); - menu.appendChild( - createItem( - `Bind ${selectedCount} geo đang chọn vào geo này`, - () => { - onBindGeometries(targetId, sourceIds); - } - ) - ); - hasMenuItems = true; - } - } - } else { - const effectiveCount = selectedCount || 1; + type MenuItem = { + label: string; + onClick: () => void; + group: "edit" | "bind" | "replay" | "delete"; + }; + + const items: MenuItem[] = []; + + if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) { + const sourceIds = Array.from(selectedIds); + items.push({ + group: "bind", + label: `Bind ${selectedCount} geo đang chọn vào geo này`, + onClick: () => { + onBindGeometries(targetId, sourceIds); + }, + }); + } + + if (!isClickOutsideSelection) { if ( effectiveCount === 1 && clickedFeature.source === "countries" && @@ -285,59 +292,74 @@ export function initSelect( onEdit ) { const single = clickedFeature; - menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); - hasMenuItems = true; + items.push({ + group: "edit", + label: "Chỉnh sửa", + onClick: () => onEdit(single), + }); } - if (effectiveCount === 1 && onDuplicate) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); - hasMenuItems = true; - } + if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) { + items.push({ + group: "edit", + label: "Duplicate", + onClick: () => onDuplicate(targetId), + }); } - if (effectiveCount === 1 && onHide) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Hide", () => onHide(featureId))); - hasMenuItems = true; - } - } - - if (onReplayEdit) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId) { - menu.appendChild( - createItem( - effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", - () => onReplayEdit(featureId) - ) - ); - hasMenuItems = true; - } - } - - if (onDelete) { - menu.appendChild( - createItem( - effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", - () => { - const ids = selectedIds.size - ? Array.from(selectedIds) - : [clickedFeature.id ?? clickedFeature.properties?.id]; - ids.forEach((eachId) => { - if (eachId !== undefined && eachId !== null) onDelete(eachId); - }); - clearSelection(); - } - ) - ); - hasMenuItems = true; + if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { + items.push({ + group: "edit", + label: "Hide", + onClick: () => onHide(targetId), + }); } } - if (!hasMenuItems) return; + if (onReplayEdit) { + const replayId = targetId; + if (replayId !== undefined && replayId !== null) { + const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount; + items.push({ + group: "replay", + label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay", + onClick: () => onReplayEdit(replayId), + }); + } + } + + if (onDelete) { + items.push({ + group: "delete", + label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", + onClick: () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [targetId]; + if (ids.length === 1) { + onDelete(ids[0]); + } else { + onDelete(ids); + } + clearSelection(); + }, + }); + } + + if (items.length === 0) return; + + let lastGroup: string | null = null; + items.forEach((item) => { + if (lastGroup !== null && lastGroup !== item.group) { + const separator = document.createElement("div"); + separator.style.height = "1px"; + separator.style.background = "#374151"; + separator.style.margin = "4px 0"; + menu.appendChild(separator); + } + menu.appendChild(createItem(item.label, item.onClick)); + lastGroup = item.group; + }); document.body.appendChild(menu); contextMenu = menu; diff --git a/src/uhm/lib/map/engines/selectingEngine.ts.orig b/src/uhm/lib/map/engines/selectingEngine.ts.orig new file mode 100644 index 0000000..4ffd708 --- /dev/null +++ b/src/uhm/lib/map/engines/selectingEngine.ts.orig @@ -0,0 +1,357 @@ +import maplibregl from "maplibre-gl"; +import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; + +// Khởi tạo engine chọn feature và context menu edit/delete. +export function initSelect( + map: maplibregl.Map, + getMode: ModeGetter, + onDelete?: (id: string | number) => void, + onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, + onDuplicate?: (id: string | number) => void, + onHide?: (id: string | number) => void, + onSelectIds?: (ids: (string | number)[]) => void, + onReplayEdit?: (id: string | number) => void, + isEditSessionActive?: () => boolean, + onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void +) { + + const FEATURE_STATE_SOURCES = [ + "countries", + "places", + "path-arrow-shapes", + ] as const; + const selectedIds = new Set(); + const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); + let contextMenu: HTMLDivElement | null = null; + let docClickHandler: ((ev: MouseEvent) => void) | null = null; + + // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn. + function clearSelection(emit = true) { + if (!selectedIds.size) return; + selectedIds.forEach((id) => setSelectionStateForId(id, false)); + selectedIds.clear(); + if (emit) { + onSelectIds?.([]); + } + } + + // Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn. + function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) { + const id = feature.id ?? feature.properties?.id; + if (id === undefined || id === null) return; + + if (!additive) { + clearSelection(); + } + + const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id)); + const isAlreadySelected = idToRemove !== undefined; + + if (additive && isAlreadySelected) { + // Alt + click on an already selected feature removes it from the selection + setSelectionStateForId(idToRemove, false); + selectedIds.delete(idToRemove); + onSelectIds?.(Array.from(selectedIds)); + return; + } + + setSelectionStateForId(id, true); + selectedIds.add(id); + onSelectIds?.(Array.from(selectedIds)); + } + + // Chọn feature theo click trái, hỗ trợ additive bằng Alt. + function onClick(e: maplibregl.MapLayerMouseEvent) { + if (getMode() !== "select" && getMode() !== "replay") return; + if (isEditSessionActive?.()) return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) return; + + const features = map.queryRenderedFeatures(e.point, { + layers: selectableLayers, + }) as maplibregl.MapGeoJSONFeature[]; + + if (!features.length) { + clearSelection(); + return; + } + + const additive = !!e.originalEvent?.altKey; + selectFeature(pickPreferredFeature(features), additive); + } + + // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải. + // Mở menu thao tác khi click phải lên feature. + function onRightClick(e: maplibregl.MapLayerMouseEvent) { + if (getMode() !== "select" && getMode() !== "replay") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) return; + + e.preventDefault(); // block browser menu + if (getMode() === "replay") return; + if (isEditSessionActive?.()) return; + + const features = map.queryRenderedFeatures(e.point, { + layers: selectableLayers, + }) as maplibregl.MapGeoJSONFeature[]; + + if (!features.length) return; + + const feature = pickPreferredFeature(features); + const id = feature.id ?? feature.properties?.id; + if (id === undefined || id === null) return; + + const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id)); + const hasSelection = selectedIds.size > 0; + + // If the right-clicked item is not selected, and there is no active selection, + // make it the sole selection. If there is an active selection, do not clear it + // so we can bind the active selection to this target geometry. + if (!isRightClickedItemAlreadySelected && !hasSelection) { + clearSelection(); + selectFeature(feature, false); + } + + showContextMenu( + e.originalEvent?.clientX ?? e.point.x, + e.originalEvent?.clientY ?? e.point.y, + feature, + isRightClickedItemAlreadySelected, + hasSelection + ); + } + + // Đổi cursor pointer khi hover lên đối tượng có thể chọn. + function onMove(e: maplibregl.MapLayerMouseEvent) { + if (getMode() !== "select" && getMode() !== "replay") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) { + map.getCanvas().style.cursor = ""; + return; + } + + const features = map.queryRenderedFeatures(e.point, { + layers: selectableLayers, + }); + + map.getCanvas().style.cursor = features.length ? "pointer" : ""; + } + + function getSelectableLayers(): string[] { + const style = map.getStyle(); + if (!style || !style.layers) return []; + return style.layers + .filter((layer) => + "source" in layer && + typeof layer.source === "string" && + FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number]) + ) + .map((layer) => layer.id); + } + + function setSelectionStateForId(id: string | number, selected: boolean) { + for (const source of FEATURE_STATE_SOURCES) { + if (!map.getSource(source)) continue; + map.setFeatureState({ source, id }, { selected }); + } + } + + 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; + } + + // Đồng bộ selection state từ React. + function syncSelection(ids: (string | number)[]) { + const nextSet = new Set(ids); + selectedIds.forEach((id) => { + if (!nextSet.has(id)) { + setSelectionStateForId(id, false); + } + }); + selectedIds.clear(); + ids.forEach((id) => { + setSelectionStateForId(id, true); + selectedIds.add(id); + }); + } + + map.on("click", onClick); + map.on("mousemove", onMove); + if (hasContextActions) { + map.on("contextmenu", onRightClick); + } + + const cleanup = () => { + map.off("click", onClick); + map.off("mousemove", onMove); + if (hasContextActions) { + map.off("contextmenu", onRightClick); + } + clearSelection(false); + hideContextMenu(); + }; + + return { + cleanup, + clearSelection, + syncSelection, + }; + + // Ẩn và dọn dẹp context menu hiện tại. + function hideContextMenu() { + if (contextMenu) { + contextMenu.remove(); + contextMenu = null; + } + if (docClickHandler) { + document.removeEventListener("click", docClickHandler); + docClickHandler = null; + } + } + + // Render menu ngữ cảnh tối giản gần vị trí con trỏ. + function showContextMenu( + x: number, + y: number, + clickedFeature: maplibregl.MapGeoJSONFeature, + isRightClickedItemAlreadySelected: boolean, + hasSelection: boolean + ) { + hideContextMenu(); + + const menu = document.createElement("div"); + menu.style.position = "fixed"; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + menu.style.background = "#0f172a"; + menu.style.color = "white"; + menu.style.border = "1px solid #1f2937"; + menu.style.borderRadius = "6px"; + menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; + menu.style.zIndex = "9999"; + menu.style.minWidth = "120px"; + menu.style.fontSize = "14px"; + menu.style.padding = "4px 0"; + + // Tạo một item thao tác trong context menu. + const createItem = (label: string, onClick: () => void) => { + const item = document.createElement("div"); + item.textContent = label; + item.style.padding = "8px 12px"; + item.style.cursor = "pointer"; + item.onmouseenter = () => (item.style.background = "#1f2937"); + item.onmouseleave = () => (item.style.background = "transparent"); + item.onclick = () => { + onClick(); + hideContextMenu(); + }; + return item; + }; + + const selectedCount = selectedIds.size; + let hasMenuItems = false; + + const effectiveCount = selectedCount || 1; + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; + + if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) { + const sourceIds = Array.from(selectedIds); + menu.appendChild( + createItem( + `Bind ${selectedCount} geo đang chọn vào geo này`, + () => { + onBindGeometries(targetId, sourceIds); + } + ) + ); + hasMenuItems = true; + + const separator = document.createElement("div"); + separator.style.height = "1px"; + separator.style.background = "#374151"; + separator.style.margin = "4px 0"; + menu.appendChild(separator); + } + + if (!isClickOutsideSelection) { + if ( + effectiveCount === 1 && + clickedFeature.source === "countries" && + clickedFeature.geometry?.type === "Polygon" && + onEdit + ) { + const single = clickedFeature; + menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); + hasMenuItems = true; + } + + if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) { + menu.appendChild(createItem("Duplicate", () => onDuplicate(targetId))); + hasMenuItems = true; + } + + if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { + menu.appendChild(createItem("Hide", () => onHide(targetId))); + hasMenuItems = true; + } + } + + if (onReplayEdit) { + const replayId = isClickOutsideSelection ? Array.from(selectedIds)[0] : targetId; + if (replayId !== undefined && replayId !== null) { + menu.appendChild( + createItem( + effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", + () => onReplayEdit(replayId) + ) + ); + hasMenuItems = true; + } + } + + if (onDelete) { + menu.appendChild( + createItem( + effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", + () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [targetId]; + ids.forEach((eachId) => { + if (eachId !== undefined && eachId !== null) onDelete(eachId); + }); + clearSelection(); + } + ) + ); + hasMenuItems = true; + } + + if (!hasMenuItems) return; + + document.body.appendChild(menu); + contextMenu = menu; + + // Đóng menu khi click ra ngoài vùng menu. + const onDocClick = (ev: MouseEvent) => { + if (!menu.contains(ev.target as Node)) { + hideContextMenu(); + } + }; + docClickHandler = onDocClick; + setTimeout(() => document.addEventListener("click", onDocClick), 0); + } +}