diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index b6c04aa..e7a9970 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -63,6 +63,7 @@ import { import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { useFeatureCommands } from "@/uhm/lib/editor/geometry/useFeatureCommands"; import { deleteSubmission } from "@/uhm/api/projects"; +import type { EntitySnapshot } from "@/uhm/types/entities"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import { @@ -820,6 +821,21 @@ function EditorPageContent() { globalGeometries.features, ]); + const localFeatureIds = useMemo(() => { + const ids = new Set(); + for (const feature of editor.mainDraft.features) { + if (feature.properties?.id !== undefined && feature.properties.id !== null) { + ids.add(feature.properties.id); + } + } + for (const feature of baselineFeatureCollection.features) { + if (feature.properties?.id !== undefined && feature.properties.id !== null) { + ids.add(feature.properties.id); + } + } + return Array.from(ids); + }, [baselineFeatureCollection.features, editor.mainDraft.features]); + // 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 []; @@ -2419,6 +2435,62 @@ function EditorPageContent() { setSelectedFeatureIds, ]); + // Add geometry đang xem từ global mode vào draft local, kèm entity refs đã map được. + const handleAddGlobalGeometryToProject = useCallback((feature: Feature) => { + const geoId = String(feature?.properties?.id || "").trim(); + if (!geoId) return; + + const existing = editor.mainDraft.features.find((item) => String(item.properties.id) === geoId) || null; + if (existing) { + setSelectedFeatureIds([existing.properties.id]); + flashEntityFormStatus("Geometry này đã nằm trong project.", 3000); + return; + } + + if (isGlobalLoading) { + flashEntityFormStatus("Đang tải global geometry và entity mapping, thử lại sau.", 3000); + return; + } + + const entityRefs = buildEntityRefsForFeature(feature, entities); + const entityIds = entityRefs.map((entity) => String(entity.id)); + const featureClone = deepClone(feature); + const nextFeature: Feature = { + ...featureClone, + properties: { + ...featureClone.properties, + id: geoId, + source: "ref", + ...buildFeatureEntityPatch(featureClone, entityIds, entityRefs), + }, + }; + const entitySnapshots = entityRefs.map(toEntityRefSnapshot); + + editor.createFeatureWithSnapshotEntityRows( + nextFeature, + (prev) => mergeSnapshotEntityRefs(prev, entitySnapshots), + `Add global GEO #${geoId}` + ); + + if (entityRefs.length) { + setEntityCatalog((prev) => mergeEntityCatalogById(prev, entityRefs)); + } + setSelectedFeatureIds([nextFeature.properties.id]); + flashEntityFormStatus( + entityRefs.length + ? `Đã add geometry global vào project kèm ${entityRefs.length} entity. Commit khi sẵn sàng.` + : "Đã add geometry global vào project. Geometry này chưa có entity mapping.", + 3000 + ); + }, [ + editor, + entities, + flashEntityFormStatus, + isGlobalLoading, + setEntityCatalog, + setSelectedFeatureIds, + ]); + // Commands thao tác metadata/entity binding cho feature đang chọn. const featureCommands = useFeatureCommands({ editor, @@ -2826,6 +2898,7 @@ function EditorPageContent() { selectedFeatureIds={selectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds} onCreateFeature={handleCreateFeature} + onAddFeatureToProject={handleAddGlobalGeometryToProject} onDeleteFeature={(id) => { if (Array.isArray(id)) { editor.deleteFeatures(id); @@ -2870,6 +2943,7 @@ function EditorPageContent() { imageOverlay={imageOverlay} onImageOverlayChange={setImageOverlay} onBindGeometries={handleBindGeometries} + localFeatureIds={localFeatureIds} showViewportControls={!isReplayPreviewMode || replayPreview.zoomPanelVisible} isPreviewMode={isAnyPreviewMode} onEnterPreview={!isReplayEditMode && !isAnyPreviewMode ? openViewerPreview : undefined} @@ -3586,6 +3660,96 @@ function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity }; } +function buildEntityRefsForFeature(feature: Feature, entities: Entity[]): Entity[] { + const entityIds = normalizeFeatureEntityIds(feature); + if (!entityIds.length) return []; + + const entityById = new globalThis.Map(); + for (const entity of entities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + entityById.set(id, entity); + } + + const entityNames = Array.isArray(feature.properties.entity_names) + ? feature.properties.entity_names + : []; + const primaryName = typeof feature.properties.entity_name === "string" + ? feature.properties.entity_name.trim() + : ""; + + return entityIds.map((id, index) => { + const catalogEntity = entityById.get(id); + if (catalogEntity) return catalogEntity; + + const name = String(entityNames[index] || (index === 0 ? primaryName : "") || id).trim() || id; + return { + id, + name, + description: null, + time_start: null, + time_end: null, + geometry_count: 0, + }; + }); +} + +function toEntityRefSnapshot(entity: Entity): EntitySnapshot { + return { + id: String(entity.id), + source: "ref", + operation: "reference", + name: entity.name, + description: entity.description ?? null, + time_start: normalizeTimelineYearValue(entity.time_start), + time_end: normalizeTimelineYearValue(entity.time_end), + }; +} + +function mergeSnapshotEntityRefs(prev: EntitySnapshot[], refs: EntitySnapshot[]): EntitySnapshot[] { + if (!refs.length) return prev; + + const refsById = new globalThis.Map(); + for (const ref of refs) { + const id = String(ref?.id || "").trim(); + if (!id) continue; + refsById.set(id, ref); + } + if (!refsById.size) return prev; + + let changed = false; + const seen = new Set(); + const next = (prev || []).map((row) => { + const id = String(row?.id || "").trim(); + if (!id || !refsById.has(id)) return row; + seen.add(id); + if (row.operation !== "delete") return row; + changed = true; + return refsById.get(id) || row; + }); + + const missing = Array.from(refsById.values()).filter((ref) => !seen.has(String(ref.id))); + if (missing.length) changed = true; + return changed ? [...missing, ...next] : prev; +} + +function mergeEntityCatalogById(prev: Entity[], refs: Entity[]): Entity[] { + if (!refs.length) return prev; + + const byId = new globalThis.Map(); + for (const entity of prev || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + byId.set(id, entity); + } + for (const entity of refs) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + byId.set(id, entity); + } + return Array.from(byId.values()); +} + function parseOptionalEntityYearInput(value: string, fieldName: string): number | undefined { const trimmed = String(value || "").trim(); if (!trimmed.length) return undefined; diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index a22af9f..ce01761 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -46,6 +46,7 @@ type MapProps = { labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; + onAddFeatureToProject?: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature?: (id: string | number | (string | number)[]) => void; onHideFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; @@ -61,6 +62,7 @@ type MapProps = { imageOverlay?: MapImageOverlay | null; onImageOverlayChange?: (overlay: MapImageOverlay) => void; onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; + localFeatureIds?: (string | number)[]; showViewportControls?: boolean; isPreviewMode?: boolean; onEnterPreview?: () => void; @@ -81,6 +83,7 @@ const Map = forwardRef(function Map({ labelContextDraft, labelTimelineYear, onCreateFeature, + onAddFeatureToProject, onDeleteFeature, onHideFeature, onUpdateFeature, @@ -96,6 +99,7 @@ const Map = forwardRef(function Map({ imageOverlay = null, onImageOverlayChange, onBindGeometries, + localFeatureIds, showViewportControls = true, isPreviewMode = false, onEnterPreview, @@ -116,6 +120,8 @@ const Map = forwardRef(function Map({ const onFeatureClickRef = useRef(onFeatureClick); // Ref callback create mới nhất khi drawing engine tạo feature. const onCreateRef = useRef(onCreateFeature); + // Ref callback add geometry global vào project mới nhất cho context menu select. + const onAddFeatureToProjectRef = useRef(onAddFeatureToProject); // Ref callback delete mới nhất khi editing engine xóa feature. const onDeleteRef = useRef(onDeleteFeature); // Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map. @@ -128,19 +134,23 @@ const Map = forwardRef(function Map({ const onImageOverlayChangeRef = useRef(onImageOverlayChange); // Ref callback bind geometry mới nhất để interaction không stale. const onBindGeometriesRef = useRef(onBindGeometries); - + // Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature. + const localFeatureIdsRef = useRef(localFeatureIds); + useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); + useEffect(() => { onAddFeatureToProjectRef.current = onAddFeatureToProject; }, [onAddFeatureToProject]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]); + useEffect(() => { localFeatureIdsRef.current = localFeatureIds; }, [localFeatureIds]); // Hook sở hữu lifecycle MapLibre instance và các control camera/projection. const { @@ -189,6 +199,8 @@ const Map = forwardRef(function Map({ onUpdateRef, onFeatureClickRef, onBindGeometriesRef, + localFeatureIdsRef, + onAddFeatureToProjectRef, }); // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index cdb4208..a16effa 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -36,6 +36,8 @@ type UseMapInteractionProps = { onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onFeatureClickRef: React.MutableRefObject<((payload: MapFeaturePayload | null) => void) | undefined>; onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>; + localFeatureIdsRef?: React.MutableRefObject<(string | number)[] | undefined>; + onAddFeatureToProjectRef?: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>; }; export function useMapInteraction({ @@ -53,6 +55,8 @@ export function useMapInteraction({ onUpdateRef, onFeatureClickRef, onBindGeometriesRef, + localFeatureIdsRef, + onAddFeatureToProjectRef, }: UseMapInteractionProps) { const editingEngineRef = useRef | null>(null); const engineBindingsRef = useRef>>({}); @@ -199,7 +203,27 @@ export function useMapInteraction({ ...payload, feature: currentFeature, }); - } + }, + onAddFeatureToProjectRef?.current + ? (feature) => { + const rawId = feature.id ?? feature.properties?.id; + if (rawId === undefined || rawId === null) return; + + const originalFeature = renderDraftRef.current.features.find( + (item) => String(item.properties.id) === String(rawId) + ); + if (!originalFeature) return; + onAddFeatureToProjectRef.current?.(originalFeature); + } + : undefined, + onAddFeatureToProjectRef?.current + ? (id) => { + if (!onAddFeatureToProjectRef?.current) return true; + const localIds = localFeatureIdsRef?.current; + if (!Array.isArray(localIds)) return true; + return localIds.some((localId) => String(localId) === String(id)); + } + : undefined ); const cleanupPoint = initPoint( diff --git a/src/uhm/lib/map/engines/selectingEngine.ts b/src/uhm/lib/map/engines/selectingEngine.ts index 2bfd05a..0761699 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -19,7 +19,9 @@ export function initSelect( onReplayEdit?: (id: string | number) => void, isEditSessionActive?: () => boolean, onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void, - onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void + onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void, + onAddToProject?: (feature: maplibregl.MapGeoJSONFeature) => void, + isLocalFeature?: (id: string | number) => boolean ) { const FEATURE_STATE_SOURCES = [ @@ -28,7 +30,7 @@ export function initSelect( "path-arrow-shapes", ] as const; const selectedIds = new Set(); - const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); + const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries || onAddToProject); let contextMenu: HTMLDivElement | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null; let cursorTimer: number | null = null; @@ -306,31 +308,48 @@ export function initSelect( return item; }; - const selectedCount = selectedIds.size; - const effectiveCount = selectedCount || 1; const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + const hasTargetId = targetId !== undefined && targetId !== null; + const isLocalTarget = hasTargetId ? (isLocalFeature?.(targetId) ?? true) : false; + const selectedLocalIds = Array.from(selectedIds).filter((id) => isLocalFeature?.(id) ?? true); + const localActionIds = selectedLocalIds.length + ? selectedLocalIds + : isLocalTarget && hasTargetId + ? [targetId] + : []; + const effectiveCount = localActionIds.length; const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; type MenuItem = { label: string; onClick: () => void; - group: "edit" | "bind" | "replay" | "delete"; + group: "add" | "edit" | "bind" | "replay" | "delete"; }; const items: MenuItem[] = []; - if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) { - const sourceIds = Array.from(selectedIds); + if (onAddToProject && hasTargetId && !isLocalTarget) { items.push({ - group: "bind", - label: `Bind ${selectedCount} geo đang chọn vào geo này`, - onClick: () => { - onBindGeometries(targetId, sourceIds); - }, + group: "add", + label: "Add", + onClick: () => onAddToProject(clickedFeature), }); } - if (!isClickOutsideSelection) { + if (isClickOutsideSelection && onBindGeometries && isLocalTarget && hasTargetId) { + const sourceIds = selectedLocalIds.filter((id) => String(id) !== String(targetId)); + if (sourceIds.length) { + items.push({ + group: "bind", + label: `Bind ${sourceIds.length} geo đang chọn vào geo này`, + onClick: () => { + onBindGeometries(targetId, sourceIds); + }, + }); + } + } + + if (isLocalTarget && !isClickOutsideSelection) { if ( effectiveCount === 1 && clickedFeature.source === "countries" && @@ -345,7 +364,7 @@ export function initSelect( }); } - if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) { + if (effectiveCount === 1 && onDuplicate && hasTargetId) { items.push({ group: "edit", label: "Duplicate", @@ -353,7 +372,7 @@ export function initSelect( }); } - if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { + if (effectiveCount === 1 && onHide && hasTargetId) { items.push({ group: "edit", label: "Hide", @@ -362,10 +381,10 @@ export function initSelect( } } - if (onReplayEdit) { + if (isLocalTarget && onReplayEdit) { const replayId = targetId; if (replayId !== undefined && replayId !== null) { - const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount; + const totalCount = isClickOutsideSelection ? selectedLocalIds.length + 1 : effectiveCount; items.push({ group: "replay", label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay", @@ -374,18 +393,15 @@ export function initSelect( } } - if (onDelete) { + if (isLocalTarget && onDelete && effectiveCount > 0) { 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]); + if (localActionIds.length === 1) { + onDelete(localActionIds[0]); } else { - onDelete(ids); + onDelete(localActionIds); } clearSelection(); },