json import export project | UI cleaner | binding geometry to each other

This commit is contained in:
taDuc
2026-05-08 23:26:27 +07:00
parent ce4bc4f2a5
commit c945a56a33
18 changed files with 1362 additions and 380 deletions
+8 -2
View File
@@ -4,6 +4,9 @@ import type {
Geometry,
GeometryChange,
} from "@/uhm/types/geo";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
export type Change = GeometryChange;
@@ -11,5 +14,8 @@ export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] };
| { type: "create"; id: FeatureProperties["id"] }
// 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[] };
+12
View File
@@ -75,6 +75,18 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
);
}
case "snapshot_entities": {
const next = b as Extract<UndoAction, { type: "snapshot_entities" }>;
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
}
case "snapshot_wikis": {
const next = b as Extract<UndoAction, { type: "snapshot_wikis" }>;
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
}
case "snapshot_entity_wiki": {
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
}
default:
return false;
}
@@ -11,7 +11,7 @@ import {
} from "@/uhm/api/sections";
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
@@ -306,6 +306,8 @@ function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
return {
...snapshot,
entities: toEditorSessionEntities(snapshot.entities),
geometries: toEditorSessionGeometries(snapshot.geometries),
geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity),
wikis: toEditorSessionWikis(snapshot.wikis),
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
};
@@ -315,6 +317,7 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
const rows = Array.isArray(input) ? input : [];
return rows
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
.filter((e) => (e as any).operation !== "delete")
.map((e) => {
const { operation: _op, ...rest } = e;
const id = String(e.id);
@@ -328,10 +331,52 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
});
}
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number"))
.filter((g) => (g as any).operation !== "delete")
.map((g) => {
const { operation: _op, ...rest } = g as any;
const id = String((g as any).id);
const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref";
return {
...(rest as Omit<GeometrySnapshot, "id" | "source" | "operation">),
id,
source,
operation: "reference",
};
});
}
function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] {
const rows = Array.isArray(input) ? input : [];
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
for (const row of rows) {
if (!row) continue;
if ((row as any).operation === "delete") continue;
const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number"
? String((row as any).geometry_id).trim()
: "";
const entity_id = typeof (row as any).entity_id === "string" || typeof (row as any).entity_id === "number"
? String((row as any).entity_id).trim()
: "";
if (!geometry_id || !entity_id) continue;
const key = `${geometry_id}::${entity_id}`;
deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash });
}
return Array.from(deduped.values()).sort((a, b) => {
const g = a.geometry_id.localeCompare(b.geometry_id);
if (g !== 0) return g;
return a.entity_id.localeCompare(b.entity_id);
});
}
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.filter((w) => (w as any).operation !== "delete")
.map((w) => {
const { operation: _op, ...rest } = w;
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
@@ -353,7 +398,7 @@ function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): E
const wiki_id = row.wiki_id.trim();
if (!entity_id || !wiki_id) continue;
const key = `${entity_id}::${wiki_id}`;
deduped.set(key, { entity_id, wiki_id, operation: "binding" });
deduped.set(key, { entity_id, wiki_id, operation: "reference" });
}
return Array.from(deduped.values()).sort((a, b) => {
const e = a.entity_id.localeCompare(b.entity_id);
+112 -27
View File
@@ -162,21 +162,19 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
.map((r) => {
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
const wiki_id = typeof r.wiki_id === "string" ? r.wiki_id : "";
const opRaw = typeof r.operation === "string" ? r.operation : "";
const opRaw = typeof r.operation === "string" ? r.operation.trim() : "";
const isDeleted =
typeof r.is_deleted === "number"
? r.is_deleted === 1
: typeof r.is_deleted === "boolean"
? r.is_deleted
: false;
const operation: "binding" | "delete" =
opRaw === "delete"
const operation: EntityWikiLinkSnapshot["operation"] =
isDeleted || opRaw === "delete"
? "delete"
: opRaw === "binding" || opRaw === "reference"
: opRaw === "binding"
? "binding"
: isDeleted
? "delete"
: "binding";
: "reference";
return { entity_id, wiki_id, operation };
})
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
@@ -190,6 +188,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const links = geometryEntity || migratedGeometryEntity || [];
const byGeom = new Map<string, string[]>();
for (const row of links) {
if ((row as any)?.operation === "delete") continue;
const list = byGeom.get(row.geometry_id) || [];
list.push(row.entity_id);
byGeom.set(row.geometry_id, list);
@@ -316,7 +315,11 @@ export function buildEditorSnapshot(options: {
? cloned.name.trim()
: id;
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
const operation = sanitizeEntitySnapshotOperation((cloned as any).operation);
const opRaw = sanitizeEntitySnapshotOperation((cloned as any).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, {
...cloned,
id,
@@ -370,7 +373,7 @@ export function buildEditorSnapshot(options: {
? "create"
: changedIds.has(id) || changedFromPreviousSnapshot
? "update"
: undefined;
: "reference";
const bbox = getFeatureBBox(feature);
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
const typeCode = typeKeyToGeoTypeCode(typeKey);
@@ -403,12 +406,44 @@ export function buildEditorSnapshot(options: {
});
}
const geometryEntityRaw: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
const geometry_id = String(feature.properties.id);
const entityIds = normalizeFeatureEntityIds(feature);
return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
});
const geometryEntity = dedupeAndSortGeometryEntity(geometryEntityRaw);
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>();
for (const row of options.previousSnapshot?.geometry_entity || []) {
if (!row) continue;
if ((row as any).operation === "delete") continue;
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
if (!geometry_id || !entity_id) continue;
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, (row as any).base_links_hash);
}
const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
const currentGeometryEntityKeys = new Set<string>();
for (const feature of options.draft.features) {
const geometry_id = String(feature.properties.id).trim();
if (!geometry_id) continue;
for (const entity_id of normalizeFeatureEntityIds(feature)) {
const key = `${geometry_id}::${entity_id}`;
if (currentGeometryEntityKeys.has(key)) continue;
currentGeometryEntityKeys.add(key);
currentGeometryEntityRows.push({
geometry_id,
entity_id,
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
base_links_hash: baselineGeometryEntity.get(key),
});
}
}
// Relations removed during this session are emitted as "delete" operations.
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
if (currentGeometryEntityKeys.has(key)) continue;
const [geometry_id, entity_id] = key.split("::");
if (!geometry_id || !entity_id) continue;
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete", base_links_hash });
}
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
@@ -429,6 +464,7 @@ export function buildEditorSnapshot(options: {
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
for (const item of options.previousSnapshot?.wikis || []) {
if (!item || typeof item !== "object") continue;
if ((item as any).operation === "delete") continue;
const id = (item as WikiSnapshot).id;
if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot);
}
@@ -437,12 +473,12 @@ export function buildEditorSnapshot(options: {
// Operation semantics:
// - create/update/delete: this commit changes the wiki itself
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
const wikis: WikiSnapshot[] = (options.snapshotWikis || [])
const wikisCurrent: WikiSnapshot[] = (options.snapshotWikis || [])
.filter((w) => {
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
if (w.source === "ref") return true;
// Keep explicit operations (e.g. delete) even if content is empty.
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
if (w.operation === "create" || w.operation === "update") return true;
// Inline wiki with no content: don't persist it (treat as not written).
const title = typeof w.title === "string" ? w.title.trim() : "";
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
@@ -485,15 +521,57 @@ export function buildEditorSnapshot(options: {
return cloned;
});
const entityWikisRaw: EntityWikiLinkSnapshot[] = (options.snapshotEntityWikiLinks || [])
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
.map((l) => ({
entity_id: l.entity_id,
wiki_id: l.wiki_id,
// Backend API expects "reference" to indicate an active link (not "binding").
operation: l.operation === "delete" ? "delete" : "reference",
}));
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
// Wikis removed during this session are emitted as "delete" operations.
const currentWikiIds = new Set(wikisCurrent.map((w) => w.id));
const deletedWikis: WikiSnapshot[] = [];
for (const prev of previousWikis.values()) {
if (!prev?.id) continue;
if (currentWikiIds.has(prev.id)) continue;
deletedWikis.push({
id: prev.id,
source: prev.source === "inline" ? "inline" : "ref",
operation: "delete",
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
slug: (prev as any).slug ?? null,
doc: (prev as any).doc ?? null,
updated_at: (prev as any).updated_at ?? undefined,
} as WikiSnapshot);
}
const wikis = [...wikisCurrent, ...deletedWikis];
const baselineEntityWiki = new Set<string>();
for (const row of options.previousSnapshot?.entity_wiki || []) {
if (!row || typeof (row as any).entity_id !== "string" || typeof (row as any).wiki_id !== "string") continue;
if ((row as any).operation === "delete") continue;
const entity_id = (row as any).entity_id.trim();
const wiki_id = (row as any).wiki_id.trim();
if (!entity_id || !wiki_id) continue;
baselineEntityWiki.add(`${entity_id}::${wiki_id}`);
}
const currentEntityWikiKeys = new Set<string>();
const entityWikisDelta: EntityWikiLinkSnapshot[] = [];
for (const l of options.snapshotEntityWikiLinks || []) {
if (!l || typeof l.entity_id !== "string" || typeof l.wiki_id !== "string") continue;
if (l.operation === "delete") continue;
const entity_id = l.entity_id.trim();
const wiki_id = l.wiki_id.trim();
if (!entity_id || !wiki_id) continue;
const key = `${entity_id}::${wiki_id}`;
if (currentEntityWikiKeys.has(key)) continue;
currentEntityWikiKeys.add(key);
entityWikisDelta.push({
entity_id,
wiki_id,
operation: baselineEntityWiki.has(key) ? "reference" : "binding",
});
}
for (const key of baselineEntityWiki) {
if (currentEntityWikiKeys.has(key)) continue;
const [entity_id, wiki_id] = key.split("::");
if (!entity_id || !wiki_id) continue;
entityWikisDelta.push({ entity_id, wiki_id, operation: "delete" });
}
const entityWikis = dedupeAndSortEntityWiki(entityWikisDelta);
return {
editor_feature_collection: draftForSnapshot,
@@ -529,10 +607,17 @@ function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEn
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
if (!geometry_id || !entity_id) continue;
const opRaw = (row as any).operation;
const operation: GeometryEntitySnapshot["operation"] =
opRaw === "delete"
? "delete"
: opRaw === "binding" || opRaw === "reference"
? opRaw
: undefined;
const key = `${geometry_id}::${entity_id}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push({ ...row, geometry_id, entity_id });
deduped.push({ ...row, geometry_id, entity_id, operation });
}
deduped.sort((a, b) => {
const g = a.geometry_id.localeCompare(b.geometry_id);
+99 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type {
Feature,
FeatureCollection,
@@ -9,15 +9,27 @@ import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor
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";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
type SnapshotUndoApi = {
snapshotEntitiesRef: { current: EntitySnapshot[] };
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
snapshotWikisRef: { current: WikiSnapshot[] };
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
};
// State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI
// - changes: map các thay đổi chờ lưu
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
export function useEditorState(initialData: FeatureCollection) {
export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) {
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
@@ -72,10 +84,25 @@ export function useEditorState(initialData: FeatureCollection) {
commitDraft({ ...draftRef.current, features: nextFeatures });
return true;
}
case "snapshot_entities": {
if (!snapshotUndo) return false;
snapshotUndo.setSnapshotEntities(deepClone(action.prev));
return true;
}
case "snapshot_wikis": {
if (!snapshotUndo) return false;
snapshotUndo.setSnapshotWikis(deepClone(action.prev));
return true;
}
case "snapshot_entity_wiki": {
if (!snapshotUndo) return false;
snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev));
return true;
}
default:
return false;
}
}, [commitDraft, draftRef]);
}, [commitDraft, draftRef, snapshotUndo]);
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
@@ -169,6 +196,71 @@ export function useEditorState(initialData: FeatureCollection) {
return initialMapRef.current.has(id);
}
const setSnapshotEntitiesUndoable = useCallback((
next: SetStateAction<EntitySnapshot[]>,
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;
});
}, [pushUndo, snapshotUndo]);
const setSnapshotWikisUndoable = useCallback((
next: SetStateAction<WikiSnapshot[]>,
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;
});
}, [pushUndo, snapshotUndo]);
const setSnapshotEntityWikiLinksUndoable = useCallback((
next: SetStateAction<EntityWikiLinkSnapshot[]>,
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;
});
}, [pushUndo, snapshotUndo]);
return {
draft,
changes,
@@ -182,5 +274,9 @@ export function useEditorState(initialData: FeatureCollection) {
buildPayload,
clearChanges,
hasPersistedFeature,
// Snapshot undo helpers (no-op if snapshotUndo not provided)
setSnapshotEntities: setSnapshotEntitiesUndoable,
setSnapshotWikis: setSnapshotWikisUndoable,
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
};
}