From 9d04076921baa48fb6ebb401bb8f56b3acc9c794 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 26 May 2026 03:14:14 +0700 Subject: [PATCH] refactor: enhance wiki navigation, add view mode toggle, and improve map sync preview logic (important global check) --- src/app/editor/[id]/page.tsx | 220 +++++++++++++++++- src/uhm/components/Map.tsx | 44 ++++ .../editor/ProjectEntityRefsPanel.tsx | 107 +++++++-- src/uhm/components/map/mapUtils.ts | 7 +- src/uhm/components/map/useMapSync.ts | 6 +- src/uhm/components/wiki/PublicWikiSidebar.tsx | 40 +++- src/uhm/components/wiki/WikiSidebarPanel.tsx | 34 ++- src/uhm/lib/editor/snapshot/editorSnapshot.ts | 74 +++--- src/uhm/lib/editor/state/useEditorState.ts | 107 +++++++++ 9 files changed, 570 insertions(+), 69 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 2c31148..3a26c8d 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -22,7 +22,8 @@ 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 { searchGeometriesByEntityName, fetchGeometriesByBBox, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; +import { WORLD_BBOX } from "@/uhm/lib/map/geo/constants"; import { Feature, FeatureCollection, @@ -409,6 +410,13 @@ function EditorPageContent() { const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState(null); const [previewActiveEntityId, setPreviewActiveEntityId] = useState(null); const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false); + + const [viewMode, setViewMode] = useState<"local" | "global">("local"); + const [globalGeometries, setGlobalGeometries] = useState({ + type: "FeatureCollection", + features: [], + }); + const [isGlobalLoading, setIsGlobalLoading] = useState(false); const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState(null); const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0); const [previewSidebarWidth, setPreviewSidebarWidth] = useState(() => { @@ -632,6 +640,119 @@ function EditorPageContent() { ? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled : timelineFilterEnabled; + // Render draft is the only FeatureCollection that decides what appears on the map. + // It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source. + // Fetch global geometries when viewMode is "global", timeline year changes, or timeline filter state changes + useEffect(() => { + if (viewMode !== "global") { + return; + } + + let disposed = false; + setIsGlobalLoading(true); + + const timeVal = activeTimelineFilterEnabled + ? clampYearToFixedRange(Math.trunc(activeTimelineYear)) + : undefined; + + const loadGlobalData = async () => { + try { + // 1. Fetch all geometries in a single fast query + const baseFc = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + time: timeVal, + timeRange: activeTimelineFilterEnabled ? 0 : undefined, + }); + + if (disposed) return; + setGlobalGeometries(baseFc); + + // 2. Concurrently fetch per-entity to build the geometry-to-entity mapping + const geoToEntities: Record = {}; + + const concurrency = 6; + const items = [...entities]; + let nextIndex = 0; + + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (true) { + if (disposed) return; + const idx = nextIndex++; + if (idx >= items.length) return; + const entity = items[idx]; + + try { + const fc = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + entity_id: entity.id, + time: timeVal, + timeRange: activeTimelineFilterEnabled ? 0 : undefined, + }); + + if (disposed) return; + + for (const feature of fc.features) { + const gid = String(feature.properties?.id); + if (!geoToEntities[gid]) { + geoToEntities[gid] = { + entity_id: entity.id, + entity_name: entity.name, + entity_ids: [entity.id], + }; + } else { + if (!geoToEntities[gid].entity_ids.includes(entity.id)) { + geoToEntities[gid].entity_ids.push(entity.id); + } + } + } + } catch (e) { + console.error(`Error loading geometry mapping for entity ${entity.id}`, e); + } + } + }) + ); + + if (disposed) return; + + // 3. Update the global geometries with the enriched properties + setGlobalGeometries((prev) => { + return { + ...prev, + features: prev.features.map((feature) => { + const gid = String(feature.properties?.id); + const mapping = geoToEntities[gid]; + if (mapping) { + return { + ...feature, + properties: { + ...feature.properties, + entity_id: mapping.entity_id, + entity_name: mapping.entity_name, + entity_ids: mapping.entity_ids, + }, + }; + } + return feature; + }), + }; + }); + } catch (err) { + console.error("Load global geometries failed", err); + } finally { + if (!disposed) { + setIsGlobalLoading(false); + } + } + }; + + loadGlobalData(); + + return () => { + disposed = true; + }; + }, [viewMode, activeTimelineYear, activeTimelineFilterEnabled, entities]); + // Render draft is the only FeatureCollection that decides what appears on the map. // It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source. const mapRenderDraft = useMemo(() => { @@ -643,11 +764,46 @@ function EditorPageContent() { ? editor.replayDraft : editor.mainDraft; - if (!activeTimelineFilterEnabled) return activeDraft; - const year = clampYearToFixedRange(Math.trunc(activeTimelineYear)); + const filteredDraft = activeTimelineFilterEnabled + ? { + ...activeDraft, + features: activeDraft.features.filter((feature) => + isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear))) + ), + } + : activeDraft; + + if (viewMode === "local") { + return filteredDraft; + } + + // We want to ignore any database geometries whose IDs are present in either the active local features + // or the baseline features (since those are owned by the local session/commit context). + const localFeatureIds = new Set(); + for (const f of filteredDraft.features) { + if (f.properties?.id != null) { + localFeatureIds.add(String(f.properties.id)); + } + } + for (const f of baselineFeatureCollection.features) { + if (f.properties?.id != null) { + localFeatureIds.add(String(f.properties.id)); + } + } + + const mergedFeatures = [...filteredDraft.features]; + + // Add global features that are not owned/modified/deleted by the local session + for (const globalFeature of globalGeometries.features) { + const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null; + if (globalId === null || !localFeatureIds.has(globalId)) { + mergedFeatures.push(globalFeature); + } + } + return { - ...activeDraft, - features: activeDraft.features.filter((feature) => isFeatureVisibleAtYear(feature, year)), + ...filteredDraft, + features: mergedFeatures, }; }, [ activeTimelineFilterEnabled, @@ -658,6 +814,9 @@ function EditorPageContent() { isViewerPreviewMode, previewSession?.draft, replayPreviewDraft, + viewMode, + baselineFeatureCollection.features, + globalGeometries.features, ]); // Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại. @@ -1913,6 +2072,16 @@ function EditorPageContent() { setSelectedGeometryEntityIds, ]); + const handleDeleteEntity = useCallback((entityId: string) => { + const id = String(entityId || "").trim(); + if (!id) return; + const confirmed = window.confirm(`Bạn có chắc chắn muốn xóa thực thể này khỏi dự án? Hành động này cũng sẽ gỡ bỏ tất cả liên kết hình học và wiki của thực thể.`); + if (!confirmed) return; + editor.deleteEntityAndRelations(id, `Xóa thực thể #${id}`); + setSelectedGeometryEntityIds((prev) => prev.filter((x) => x !== id)); + flashEntityFormStatus(`Đã xóa thực thể #${id}.`, 3000); + }, [editor, flashEntityFormStatus, setSelectedGeometryEntityIds]); + // Bind/unbind geometry con vào selected geometry qua field child.bound_with. const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { if (!selectedFeatures || selectedFeatures.length === 0) { @@ -2398,9 +2567,41 @@ function EditorPageContent() { }; // Base draft for label lookup only. It must not decide which geometry is rendered. - const labelContextBaseDraft = isAnyPreviewMode - ? previewSession?.draft || EMPTY_FEATURE_COLLECTION - : editor.draft; + const labelContextBaseDraft = useMemo(() => { + const baseDraft = isAnyPreviewMode + ? previewSession?.draft || EMPTY_FEATURE_COLLECTION + : editor.draft; + + if (viewMode === "local") { + return baseDraft; + } + + const localFeatureIds = new Set(); + for (const f of baseDraft.features) { + if (f.properties?.id != null) { + localFeatureIds.add(String(f.properties.id)); + } + } + for (const f of baselineFeatureCollection.features) { + if (f.properties?.id != null) { + localFeatureIds.add(String(f.properties.id)); + } + } + + const mergedFeatures = [...baseDraft.features]; + for (const globalFeature of globalGeometries.features) { + const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null; + if (globalId === null || !localFeatureIds.has(globalId)) { + mergedFeatures.push(globalFeature); + } + } + + return { + ...baseDraft, + features: mergedFeatures, + }; + }, [viewMode, isAnyPreviewMode, previewSession?.draft, editor.draft, baselineFeatureCollection.features, globalGeometries.features]); + // Enriched label context may contain geometries that mapRenderDraft filtered out. // Map rendering must still use mapRenderDraft above. const mapLabelContextDraft = useMemo(() => { @@ -2657,6 +2858,8 @@ function EditorPageContent() { onEnterPreview={!isReplayEditMode && !isAnyPreviewMode ? openViewerPreview : undefined} onExitPreview={isReplayPreviewMode ? exitReplayPreview : isViewerPreviewMode ? exitViewerPreview : undefined} onPlayPreviewReplay={isViewerPreviewMode && viewerPreviewSelectedReplay ? openSelectedViewerReplayPreview : undefined} + viewMode={viewMode} + onViewModeChange={setViewMode} /> ) : (
@@ -3078,6 +3281,7 @@ function EditorPageContent() { selectedGeometryTime={selectedGeometryTime} onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry} onRerollEntityId={handleRerollEntityId} + onDeleteEntity={handleDeleteEntity} /> void; onExitPreview?: () => void; onPlayPreviewReplay?: () => void; + viewMode?: "local" | "global"; + onViewModeChange?: (mode: "local" | "global") => void; }; const Map = forwardRef(function Map({ @@ -99,6 +101,8 @@ const Map = forwardRef(function Map({ onEnterPreview, onExitPreview, onPlayPreviewReplay, + viewMode = "local", + onViewModeChange, }, ref) { // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); @@ -210,6 +214,7 @@ const Map = forwardRef(function Map({ allowGeometryEditing, editingEngineRef, geolocationCenteredRef, + isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview", }); useEffect(() => { @@ -373,6 +378,45 @@ const Map = forwardRef(function Map({ + {onViewModeChange ? ( +
+ + +
+ ) : null} + {onEnterPreview || onExitPreview ? ( ) : null} + {typeof onDeleteEntity === "function" ? ( + + ) : null}
); })} @@ -346,28 +370,53 @@ export default function ProjectEntityRefsPanel({ /> - +
+ + {typeof onDeleteEntity === "function" && ( + + )} +
) : null} @@ -610,3 +659,17 @@ function ClockIcon() { ); } + +function TrashIcon() { + return ( + + ); +} diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index b3f04b2..ffc7760 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -140,7 +140,8 @@ export function getSelectableLayers(map: maplibregl.Map): string[] { export function filterDraftByBinding( fc: FeatureCollection, selectedFeatureIds: (string | number)[], - highlightFeatures?: FeatureCollection | null + highlightFeatures?: FeatureCollection | null, + isPreviewMode?: boolean ): FeatureCollection { const selectedIds = new Set(selectedFeatureIds.map(String)); if (highlightFeatures?.features) { @@ -185,8 +186,8 @@ export function filterDraftByBinding( const featureId = String(feature.properties.id); const parentId = featureParentMap.get(featureId); - // 1. If this feature is a parent and its hierarchy is active, hide it - if (activeParents.has(featureId)) { + // 1. If this feature is a parent and its hierarchy is active, hide it (only in preview/replay modes) + if (isPreviewMode && activeParents.has(featureId)) { return false; } diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index 9ac4841..d8ea6f2 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -44,6 +44,7 @@ type UseMapSyncProps = { clearEditing: () => void; } | null>; geolocationCenteredRef: React.MutableRefObject; + isPreviewMode?: boolean; }; export function useMapSync({ @@ -64,6 +65,7 @@ export function useMapSync({ allowGeometryEditing, editingEngineRef, geolocationCenteredRef, + isPreviewMode, }: UseMapSyncProps) { const renderDraftRef = useRef(renderDraft); const labelContextDraftRef = useRef(labelContextDraft); @@ -76,6 +78,7 @@ export function useMapSync({ const imageOverlayRef = useRef(imageOverlay || null); const focusFeatureCollectionRef = useRef(focusFeatureCollection); const focusPaddingRef = useRef(focusPadding); + const isPreviewModeRef = useRef(isPreviewMode); const fitBoundsAppliedRef = useRef(false); @@ -90,6 +93,7 @@ export function useMapSync({ useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]); useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]); useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]); + useEffect(() => { isPreviewModeRef.current = isPreviewMode; }, [isPreviewMode]); useEffect(() => { fitBoundsAppliedRef.current = false; @@ -119,7 +123,7 @@ export function useMapSync({ const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current; const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current - ? filterDraftByBinding(renderFc, currentSelectedIds) + ? filterDraftByBinding(renderFc, currentSelectedIds, null, isPreviewModeRef.current) : renderFc; const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current); const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft); diff --git a/src/uhm/components/wiki/PublicWikiSidebar.tsx b/src/uhm/components/wiki/PublicWikiSidebar.tsx index 4345a65..5213e63 100644 --- a/src/uhm/components/wiki/PublicWikiSidebar.tsx +++ b/src/uhm/components/wiki/PublicWikiSidebar.tsx @@ -134,6 +134,7 @@ export default function PublicWikiSidebar({ maxDragWidth, }: Props) { const contentRootRef = useRef(null); + const tocContainerRef = useRef(null); const [localWidth, setLocalWidth] = useState(() => { if (typeof window !== "undefined") { @@ -203,6 +204,7 @@ export default function PublicWikiSidebar({ .filter((item): item is HTMLElement => Boolean(item)); if (!headings.length) return; + const scrollContainer = root.parentElement; const observer = new IntersectionObserver( (entries) => { const visible = entries @@ -211,13 +213,30 @@ export default function PublicWikiSidebar({ const top = visible[0]?.target as HTMLElement | undefined; if (top?.id) setActiveHeadingId(top.id); }, - { root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] } + { root: scrollContainer || null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] } ); for (const heading of headings) observer.observe(heading); return () => observer.disconnect(); }, [toc]); + useEffect(() => { + const container = tocContainerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + if (e.deltaY !== 0) { + e.preventDefault(); + container.scrollLeft += e.deltaY; + } + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => { + container.removeEventListener("wheel", handleWheel); + }; + }, [toc]); + useEffect(() => { const root = contentRootRef.current; if (!root) return; @@ -373,6 +392,7 @@ export default function PublicWikiSidebar({ }} >
{ + e.preventDefault(); + setActiveHeadingId(item.id); + const root = contentRootRef.current; + if (root) { + const targetElement = root.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null; + const scrollContainer = root.parentElement; + if (targetElement && scrollContainer) { + const containerTop = scrollContainer.getBoundingClientRect().top; + const targetTop = targetElement.getBoundingClientRect().top; + const scrollOffset = targetTop - containerTop + scrollContainer.scrollTop; + scrollContainer.scrollTo({ + top: scrollOffset - 12, + behavior: "smooth" + }); + } + } + }} style={{ flexShrink: 0, borderRadius: 9999, diff --git a/src/uhm/components/wiki/WikiSidebarPanel.tsx b/src/uhm/components/wiki/WikiSidebarPanel.tsx index 7844ee1..6384ba8 100644 --- a/src/uhm/components/wiki/WikiSidebarPanel.tsx +++ b/src/uhm/components/wiki/WikiSidebarPanel.tsx @@ -676,17 +676,21 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: type="button" onClick={() => removeWiki(w.id)} style={{ - border: "none", - background: "#111827", - color: "#fca5a5", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 22, + height: 22, + borderRadius: 6, + border: "1px solid #334155", + background: "#0b1220", cursor: "pointer", - borderRadius: "6px", - padding: "6px 8px", - fontSize: "12px", + flex: "0 0 auto", }} - title="Remove" + title="Xóa wiki khỏi dự án" + aria-label={`Xóa wiki ${w.id}`} > - Del +
))} @@ -1063,6 +1067,20 @@ function CloseIcon() { ); } +function TrashIcon() { + return ( + + ); +} + const QUILL_TOOLBAR = [ [{ header: [1, 2, 3, false] }], [{ align: [] }, { align: "center" }, { align: "right" }], diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts index 2b0aad4..6045904 100644 --- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts +++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts @@ -482,12 +482,59 @@ export function buildEditorSnapshot(options: { // Persist inline entity records across commits even when they're not currently bound. // Without this, "create entity" can disappear on the next commit unless the entity is referenced // by geometry_entity/entity_wiki or pinned via projectEntityRefs. + const activeEntityIds = new Set(); + for (const row of options.snapshotEntityRows || []) { + if (!row) continue; + const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : ""; + if (!id) continue; + const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot; + const opRaw = sanitizeEntitySnapshotOperation((cloned as RawEntityRow).operation); + if (opRaw === "delete") { + entityRows.set(id, { + id, + source: cloned.source === "inline" ? "inline" : "ref", + operation: "delete", + }); + continue; + } + activeEntityIds.add(id); + const name = + typeof cloned?.name === "string" && cloned.name.trim().length + ? cloned.name.trim() + : id; + const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref"; + const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw; + entityRows.set(id, { + id, + source, + name, + operation, + description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, + time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, + }); + } + + // Persist inline entity records across commits even when they're not currently bound. + // If they were present in previous snapshot but are no longer in snapshotEntityRows, emit as delete. for (const prev of options.previousSnapshot?.entities || []) { if (!prev) continue; const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : ""; - if (!id || entityRows.has(id)) continue; + if (!id) continue; if (prev.operation === "delete") continue; + + if (!activeEntityIds.has(id)) { + entityRows.set(id, { + id, + source: prev.source === "inline" ? "inline" : "ref", + operation: "delete", + }); + continue; + } + + if (entityRows.has(id)) continue; if (prev.source !== "inline") continue; + // Carry forward as current-state inline entity; operation is a per-commit delta signal. const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot; delete cloned.operation; @@ -501,31 +548,6 @@ export function buildEditorSnapshot(options: { time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, }); } - for (const row of options.snapshotEntityRows || []) { - if (!row) continue; - const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : ""; - if (!id) continue; - const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot; - const name = - typeof cloned?.name === "string" && cloned.name.trim().length - ? cloned.name.trim() - : id; - const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref"; - const opRaw = sanitizeEntitySnapshotOperation((cloned as RawEntityRow).operation); - // Editor state should delete objects by removing them from the list. - // Keep this defensive guard to avoid emitting delete markers unexpectedly. - if (opRaw === "delete") continue; - const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw; - entityRows.set(id, { - id, - source, - name, - operation, - description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, - time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined, - time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, - }); - } // Entities referenced by wiki links should be present as "reference" too. for (const link of options.snapshotEntityWikiLinks || []) { diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index f87e30f..a16d711 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -883,6 +883,112 @@ export function useEditorState( } }, [pushMainUndo, snapshotUndo]); + const deleteEntityAndRelations = useCallback(( + entityId: string, + label = "Xóa entity" + ) => { + if (!snapshotUndo) return; + const id = String(entityId || "").trim(); + if (!id) return; + + const prevEntities = snapshotUndo.snapshotEntityRowsRef.current || []; + const prevEntitiesClone = deepClone(prevEntities); + + const prevWikiLinks = snapshotUndo.snapshotEntityWikiLinksRef.current || []; + const prevWikiLinksClone = deepClone(prevWikiLinks); + + const prevFeatures = mainDraftRef.current.features; + + // 1. Cập nhật snapshotEntityRows + const nextEntities = prevEntities.map((e) => { + if (String(e.id) !== id) return e; + return { + ...e, + operation: "delete" as const, + }; + }).filter((e) => { + // Loại bỏ hoàn toàn nếu là inline & create chưa commit + return !(String(e.id) === id && e.source === "inline" && e.operation === "create"); + }); + + // 2. Cập nhật snapshotEntityWikiLinks + const nextWikiLinks = prevWikiLinks.filter((link) => String(link.entity_id) !== id); + + // 3. Cập nhật draft features + const nextFeatures = prevFeatures.map((feature) => { + const properties = feature.properties; + const entityIds: string[] = Array.isArray(properties.entity_ids) + ? properties.entity_ids.map(String) + : properties.entity_id + ? [String(properties.entity_id)] + : []; + + if (entityIds.includes(id)) { + const nextEntityIds = entityIds.filter((eid) => eid !== id); + return { + ...feature, + properties: { + ...feature.properties, + entity_ids: nextEntityIds, + entity_id: nextEntityIds[0] || null, + entity_name: nextEntityIds[0] || null, + } + }; + } + return feature; + }); + + const entitiesChanged = !jsonEquals(prevEntities, nextEntities); + const linksChanged = !jsonEquals(prevWikiLinks, nextWikiLinks); + const featuresChanged = !jsonEquals(prevFeatures, nextFeatures); + + if (!entitiesChanged && !linksChanged && !featuresChanged) return; + + const undoActions: UndoAction[] = []; + if (entitiesChanged) { + undoActions.push({ type: "snapshot_entities", label: "Cập nhật entities", prev: prevEntitiesClone }); + } + if (linksChanged) { + undoActions.push({ type: "snapshot_entity_wiki", label: "Cập nhật entity-wiki", prev: prevWikiLinksClone }); + } + if (featuresChanged) { + for (let i = 0; i < prevFeatures.length; i++) { + const prevF = prevFeatures[i]; + const nextF = nextFeatures[i]; + if (!jsonEquals(prevF.properties, nextF.properties)) { + undoActions.push({ + type: "properties", + id: prevF.properties.id, + prevProperties: deepClone(prevF.properties), + }); + } + } + } + + pushMainUndo( + undoActions.length === 1 + ? undoActions[0] + : { type: "group", label, actions: undoActions } + ); + + if (entitiesChanged) { + const nextEntitiesClone = deepClone(nextEntities); + snapshotUndo.snapshotEntityRowsRef.current = nextEntitiesClone; + snapshotUndo.setSnapshotEntityRows(nextEntitiesClone); + } + if (linksChanged) { + const nextWikiLinksClone = deepClone(nextWikiLinks); + snapshotUndo.snapshotEntityWikiLinksRef.current = nextWikiLinksClone; + snapshotUndo.setSnapshotEntityWikiLinks(nextWikiLinksClone); + } + if (featuresChanged) { + commitMainDraft({ + ...mainDraftRef.current, + features: nextFeatures, + }); + } + }, [pushMainUndo, snapshotUndo, mainDraftRef, commitMainDraft]); + const undo = useCallback(() => { if (mode === "replay") { undoReplay(); @@ -928,6 +1034,7 @@ export function useEditorState( setSnapshotWikis: setSnapshotWikisUndoable, setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable, setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable, + deleteEntityAndRelations, }; }