diff --git a/src/app/editor/[id]/featureCommands.ts b/src/app/editor/[id]/featureCommands.ts index 1e57736..fbbb81d 100644 --- a/src/app/editor/[id]/featureCommands.ts +++ b/src/app/editor/[id]/featureCommands.ts @@ -12,6 +12,10 @@ import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionType type EditorDraftApi = { patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial) => void; + patchFeaturePropertiesBatch: ( + patches: Array<{ id: FeatureProperties["id"]; patch: Partial }>, + label?: string + ) => void; }; type Options = { @@ -64,9 +68,13 @@ export function useFeatureCommands(options: Options) { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - for (const feature of selectedFeatures) { - editor.patchFeatureProperties(feature.properties.id, metadata.patch); - } + editor.patchFeaturePropertiesBatch( + selectedFeatures.map((feature) => ({ + id: feature.properties.id, + patch: metadata.patch, + })), + "Cập nhật thuộc tính GEO" + ); setGeometryMetaForm(metadata.formState); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); return { ok: true }; @@ -92,12 +100,13 @@ export function useFeatureCommands(options: Options) { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - for (const feature of selectedFeatures) { - editor.patchFeatureProperties( - feature.properties.id, - buildFeatureEntityPatch(feature, entityIds, entities) - ); - } + editor.patchFeaturePropertiesBatch( + selectedFeatures.map((feature) => ({ + id: feature.properties.id, + patch: buildFeatureEntityPatch(feature, entityIds, entities), + })), + "Cập nhật entity cho GEO" + ); setSelectedGeometryEntityIds(entityIds); setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); } catch (err) { diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 01faece..21a2f9a 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -94,6 +94,7 @@ export default function Page() { key: number; collection: FeatureCollection; } | null>(null); + const localCreatedEntityIdsRef = useRef>(new Set()); const lastSelectedFeatureIdRef = useRef(null); const { @@ -240,6 +241,27 @@ export default function Page() { return Array.from(byId.values()); }, [snapshotEntities]); + 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]); + // Timeline filter: only affects persisted snapshot features. // New features created in the current session remain visible regardless of time range. const timelineVisibleDraft = useMemo(() => { @@ -906,12 +928,13 @@ export default function Page() { setIsEntitySubmitting(true); flashEntityFormStatus(null, 0); try { - for (const feature of selectedFeatures) { - editor.patchFeatureProperties( - feature.properties.id, - buildFeatureEntityPatch(feature, nextEntityIds, entities) - ); - } + 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 @@ -951,7 +974,7 @@ export default function Page() { setIsEntitySubmitting(true); flashGeoBindingStatus(null, 0); try { - for (const feature of selectedFeatures) { + const bindingPatches = selectedFeatures.map((feature) => { const prevBindingIds = normalizeFeatureBindingIds(feature); const has = prevBindingIds.includes(id); const nextBindingIds = (() => { @@ -962,8 +985,15 @@ export default function Page() { if (!has) return prevBindingIds; return prevBindingIds.filter((x) => x !== id); })(); - editor.patchFeatureProperties(feature.properties.id, { binding: nextBindingIds }); - } + 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]); @@ -1056,17 +1086,18 @@ export default function Page() { // Ensure the geometry stays selectable even if it doesn't match the current timeline year. setTimelineFilterEnabled(false); - // Keep entity store consistent: importing a geo implies the entity should exist in snapshot + catalog. - handleAddEntityRefToProject({ + const importedEntity: Entity = { id: entityItem.entity_id, name: (entityItem.name || "").trim() || entityItem.entity_id, description: (entityItem.description || "").trim() || null, status: 1, 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; @@ -1097,13 +1128,39 @@ export default function Page() { geometry, }; - editor.createFeature(feature); + 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, ]); @@ -1162,6 +1219,7 @@ export default function Page() { ...prev, ]; }, `Tạo entity #${entityId}`); + localCreatedEntityIdsRef.current.add(entityId); setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { diff --git a/src/uhm/components/editor/UndoListPanel.tsx b/src/uhm/components/editor/UndoListPanel.tsx index 6fc3580..d6966d4 100644 --- a/src/uhm/components/editor/UndoListPanel.tsx +++ b/src/uhm/components/editor/UndoListPanel.tsx @@ -48,6 +48,7 @@ export function formatUndoLabel(action: UndoAction) { case "snapshot_entities": case "snapshot_wikis": case "snapshot_entity_wiki": + case "group": return action.label; default: return "Tác vụ"; diff --git a/src/uhm/lib/editor/draft/editorTypes.ts b/src/uhm/lib/editor/draft/editorTypes.ts index 202fad5..789351a 100644 --- a/src/uhm/lib/editor/draft/editorTypes.ts +++ b/src/uhm/lib/editor/draft/editorTypes.ts @@ -18,4 +18,5 @@ export type UndoAction = // Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly) | { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] } | { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] } - | { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] }; + | { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] } + | { type: "group"; label: string; actions: UndoAction[] }; diff --git a/src/uhm/lib/editor/draft/useUndoStack.ts b/src/uhm/lib/editor/draft/useUndoStack.ts index deeb3c1..d7fc5e5 100644 --- a/src/uhm/lib/editor/draft/useUndoStack.ts +++ b/src/uhm/lib/editor/draft/useUndoStack.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff"; @@ -10,31 +10,32 @@ export function useUndoStack(options: Options) { const { applyUndoAction } = options; // Stack thao tác undo (append-only, pop khi undo). const [undoStack, setUndoStack] = useState([]); + const undoStackRef = useRef([]); const pushUndo = useCallback((action: UndoAction) => { - setUndoStack((prev) => { - const last = prev[prev.length - 1]; - if (isSameUndo(last, action)) return prev; - return [...prev, action]; - }); + const prev = undoStackRef.current; + const last = prev[prev.length - 1]; + if (isSameUndo(last, action)) return; + const next = [...prev, action]; + undoStackRef.current = next; + setUndoStack(next); }, []); const undo = useCallback(() => { - let applied = false; - setUndoStack((prev) => { - if (applied) return prev; - if (!prev.length) return prev; + const current = undoStackRef.current; + if (!current.length) return; - const last = prev[prev.length - 1]; - const remaining = prev.slice(0, -1); - applied = true; + const last = current[current.length - 1]; + const didApply = applyUndoAction(last); + if (!didApply) return; - const didApply = applyUndoAction(last); - return didApply ? remaining : prev; - }); + const remaining = current.slice(0, -1); + undoStackRef.current = remaining; + setUndoStack(remaining); }, [applyUndoAction]); const clearUndo = useCallback(() => { + undoStackRef.current = []; setUndoStack([]); }, []); @@ -87,6 +88,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) { const next = b as Extract; return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev); } + case "group": { + const next = b as Extract; + return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions); + } default: return false; } diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index 2f6e46f..2d726b4 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -5,7 +5,7 @@ import type { FeatureProperties, Geometry, } from "@/uhm/types/geo"; -import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff"; +import { buildInitialMap, deepClone, diffDraftToInitial, geometryEquals } from "@/uhm/lib/editor/draft/draftDiff"; import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState"; import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack"; import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; @@ -25,6 +25,11 @@ type SnapshotUndoApi = { setSnapshotEntityWikiLinks: Dispatch>; }; +type FeaturePropertiesPatch = { + id: FeatureProperties["id"]; + patch: Partial; +}; + // State trung tâm của editor: // - draft: dữ liệu nguồn để render UI // - changes: map các thay đổi chờ lưu @@ -86,19 +91,32 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn } case "snapshot_entities": { if (!snapshotUndo) return false; - snapshotUndo.setSnapshotEntities(deepClone(action.prev)); + const prev = deepClone(action.prev); + snapshotUndo.snapshotEntitiesRef.current = prev; + snapshotUndo.setSnapshotEntities(prev); return true; } case "snapshot_wikis": { if (!snapshotUndo) return false; - snapshotUndo.setSnapshotWikis(deepClone(action.prev)); + const prev = deepClone(action.prev); + snapshotUndo.snapshotWikisRef.current = prev; + snapshotUndo.setSnapshotWikis(prev); return true; } case "snapshot_entity_wiki": { if (!snapshotUndo) return false; - snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev)); + const prev = deepClone(action.prev); + snapshotUndo.snapshotEntityWikiLinksRef.current = prev; + snapshotUndo.setSnapshotEntityWikiLinks(prev); return true; } + case "group": { + let applied = true; + for (let i = action.actions.length - 1; i >= 0; i -= 1) { + applied = applyUndoAction(action.actions[i]) && applied; + } + return applied; + } default: return false; } @@ -129,6 +147,51 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn pushUndo({ type: "create", id: featureClone.properties.id }); } + function createFeatureWithSnapshotEntities( + feature: Feature, + nextEntities: SetStateAction, + label = "Import geometry" + ) { + const featureClone = deepClone(feature); + const undoActions: UndoAction[] = []; + + if (snapshotUndo) { + const prevEntities = snapshotUndo.snapshotEntitiesRef.current || []; + const prevEntitiesClone = deepClone(prevEntities); + const computedEntities = typeof nextEntities === "function" + ? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone) + : nextEntities; + let entitiesChanged = true; + try { + entitiesChanged = JSON.stringify(prevEntities) !== JSON.stringify(computedEntities); + } catch { + entitiesChanged = true; + } + + if (entitiesChanged) { + const computedEntitiesClone = deepClone(computedEntities); + undoActions.push({ + type: "snapshot_entities", + label: "Cập nhật entities", + prev: prevEntitiesClone, + }); + snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone; + snapshotUndo.setSnapshotEntities(computedEntitiesClone); + } + } + + undoActions.push({ type: "create", id: featureClone.properties.id }); + pushUndo( + undoActions.length === 1 + ? undoActions[0] + : { type: "group", label, actions: undoActions } + ); + commitDraft({ + ...draftRef.current, + features: [...draftRef.current.features, featureClone], + }); + } + function patchFeatureProperties( id: FeatureProperties["id"], patch: Partial @@ -154,12 +217,63 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn commitDraft({ ...draftRef.current, features: nextFeatures }); } + function patchFeaturePropertiesBatch( + patches: FeaturePropertiesPatch[], + label = "Cập nhật nhiều geometry" + ) { + const mergedPatches = new Map>(); + for (const item of patches || []) { + if (!item) continue; + const prev = mergedPatches.get(item.id) || {}; + mergedPatches.set(item.id, { + ...prev, + ...deepClone(item.patch), + }); + } + if (!mergedPatches.size) return; + + const nextFeatures = [...draftRef.current.features]; + const undoActions: UndoAction[] = []; + + for (const [id, patch] of mergedPatches.entries()) { + const idx = nextFeatures.findIndex((feature) => feature.properties.id === id); + if (idx === -1) continue; + + const prevProperties = deepClone(nextFeatures[idx].properties); + const nextProperties = { + ...nextFeatures[idx].properties, + ...deepClone(patch), + }; + if (JSON.stringify(prevProperties) === JSON.stringify(nextProperties)) { + continue; + } + + nextFeatures[idx] = { + ...nextFeatures[idx], + properties: nextProperties, + }; + undoActions.push({ type: "properties", id, prevProperties }); + } + + if (!undoActions.length) return; + + pushUndo( + undoActions.length === 1 + ? undoActions[0] + : { type: "group", label, actions: undoActions } + ); + commitDraft({ ...draftRef.current, features: nextFeatures }); + } + function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) { const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id); if (idx === -1) return; const prevFeature = draftRef.current.features[idx]; const prevGeometry = deepClone(prevFeature.geometry); + if (geometryEquals(prevGeometry, newGeometry)) { + return; + } const nextFeatures = [...draftRef.current.features]; nextFeatures[idx] = { ...prevFeature, @@ -201,20 +315,21 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn label = "Cập nhật entities" ) => { if (!snapshotUndo) return; - snapshotUndo.setSnapshotEntities((prev) => { - const prevClone = deepClone(prev); - const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prev) : next; - let changed = true; - try { - changed = JSON.stringify(prev) !== JSON.stringify(computed); - } catch { - changed = true; - } - if (changed) { - pushUndo({ type: "snapshot_entities", label, prev: prevClone }); - } - return computed; - }); + const prev = snapshotUndo.snapshotEntitiesRef.current || []; + const prevClone = deepClone(prev); + const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next; + let changed = true; + try { + changed = JSON.stringify(prev) !== JSON.stringify(computed); + } catch { + changed = true; + } + if (!changed) return; + + const computedClone = deepClone(computed); + pushUndo({ type: "snapshot_entities", label, prev: prevClone }); + snapshotUndo.snapshotEntitiesRef.current = computedClone; + snapshotUndo.setSnapshotEntities(computedClone); }, [pushUndo, snapshotUndo]); const setSnapshotWikisUndoable = useCallback(( @@ -222,20 +337,21 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn label = "Cập nhật wikis" ) => { if (!snapshotUndo) return; - snapshotUndo.setSnapshotWikis((prev) => { - const prevClone = deepClone(prev); - const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prev) : next; - let changed = true; - try { - changed = JSON.stringify(prev) !== JSON.stringify(computed); - } catch { - changed = true; - } - if (changed) { - pushUndo({ type: "snapshot_wikis", label, prev: prevClone }); - } - return computed; - }); + const prev = snapshotUndo.snapshotWikisRef.current || []; + const prevClone = deepClone(prev); + const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prevClone) : next; + let changed = true; + try { + changed = JSON.stringify(prev) !== JSON.stringify(computed); + } catch { + changed = true; + } + if (!changed) return; + + const computedClone = deepClone(computed); + pushUndo({ type: "snapshot_wikis", label, prev: prevClone }); + snapshotUndo.snapshotWikisRef.current = computedClone; + snapshotUndo.setSnapshotWikis(computedClone); }, [pushUndo, snapshotUndo]); const setSnapshotEntityWikiLinksUndoable = useCallback(( @@ -243,22 +359,23 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn label = "Cập nhật entity-wiki" ) => { if (!snapshotUndo) return; - snapshotUndo.setSnapshotEntityWikiLinks((prev) => { - const prevClone = deepClone(prev); - const computed = typeof next === "function" - ? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prev) - : next; - let changed = true; - try { - changed = JSON.stringify(prev) !== JSON.stringify(computed); - } catch { - changed = true; - } - if (changed) { - pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone }); - } - return computed; - }); + const prev = snapshotUndo.snapshotEntityWikiLinksRef.current || []; + const prevClone = deepClone(prev); + const computed = typeof next === "function" + ? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prevClone) + : next; + let changed = true; + try { + changed = JSON.stringify(prev) !== JSON.stringify(computed); + } catch { + changed = true; + } + if (!changed) return; + + const computedClone = deepClone(computed); + pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone }); + snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone; + snapshotUndo.setSnapshotEntityWikiLinks(computedClone); }, [pushUndo, snapshotUndo]); return { @@ -267,7 +384,9 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn undoStack, changeCount, createFeature, + createFeatureWithSnapshotEntities, patchFeatureProperties, + patchFeaturePropertiesBatch, updateFeature, deleteFeature, undo,